Add built-in slash commands plugin

Introduce the _commands plugin with command storage, picker UI, bundled canonical command pack, and legacy migration into the built-in namespace.

Polish the Web UI picker behavior, hide WebUI-only-inappropriate commands from the popover, make /models always open model configuration, and add regression coverage for command CRUD, plugin discovery, migration, canonical names, hidden commands, and picker flows.
This commit is contained in:
Alessandro 2026-07-09 16:00:30 +02:00
parent 3d87998821
commit 9c9a4e00ca
46 changed files with 4717 additions and 0 deletions

View file

@ -70,6 +70,7 @@ Direct child DOX files:
| [_browser/AGENTS.md](_browser/AGENTS.md) | Playwright browser tool, helpers, viewer, and browser panel UI. |
| [_chat_branching/AGENTS.md](_chat_branching/AGENTS.md) | Chat branching from an existing message. |
| [_chat_compaction/AGENTS.md](_chat_compaction/AGENTS.md) | Full-chat compaction into a summary message. |
| [_commands/AGENTS.md](_commands/AGENTS.md) | Built-in slash command manager, command file discovery, and chat composer slash picker. |
| [_code_execution/AGENTS.md](_code_execution/AGENTS.md) | Terminal, Python, and Node.js execution tools and shell runtimes. |
| [_desktop/AGENTS.md](_desktop/AGENTS.md) | Linux desktop runtime, sessions, and desktop surface. |
| [_discovery/AGENTS.md](_discovery/AGENTS.md) | Welcome-screen plugin discovery cards and promotions. |

View file

@ -0,0 +1,44 @@
# Commands Plugin DOX
## Purpose
- Own the built-in slash command manager and chat composer slash picker.
- Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes.
## Ownership
- `plugin.yaml` owns the built-in `_commands` plugin metadata.
- `helpers/commands.py` owns command name sanitization, argument parsing, scope resolution, file persistence, plugin command discovery, and command invocation resolution.
- `api/commands.py` owns the Commands API actions used by the WebUI.
- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
- `commands/` owns bundled read-only slash command definitions shipped by `_commands`.
- `extensions/` owns the chat composer picker and sidebar quick-action entry.
- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
- `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
- `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
## Local Contracts
- The plugin identity is `_commands`; user-created command files live under `usr/plugins/_commands/commands/` or `usr/projects/<project>/.a0proj/plugins/_commands/commands/`.
- Each command is one `.command.yaml` config plus one same-directory `.txt` text template or `.py` script hook.
- Project commands override global commands, global commands override bundled `_commands/commands/` defaults, and bundled defaults override other plugin-distributed commands with the same name.
- Bundled `_commands/commands/` definitions and commands contributed by other plugins are read-only from this manager.
- Bundled command files use canonical command names only; do not ship alias-only built-ins such as `/img` for `/attach`.
- Command configs may set `webui_hidden: true` to stay resolvable but be omitted from the chat composer picker.
- Commands contributed by enabled plugins live in their `commands/` directory and must not be rediscovered through the generic plugin-distributed path from `_commands` itself.
- 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.
- 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.
## Work Guidance
- Keep the command storage and route namespace aligned with `_commands`.
- Preserve unknown command config keys when editing commands.
- Keep WebUI paths pointed at `/plugins/_commands/...`.
## Verification
- Run `conda run -n a0 pytest plugins/_commands/tests` after backend or command contract changes.
## Child DOX Index
No child DOX files.

21
plugins/_commands/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Commands plugin contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

146
plugins/_commands/README.md Normal file
View file

@ -0,0 +1,146 @@
# Commands
YAML-configured slash commands for Agent Zero.
This plugin lets you define reusable `/commands` as `.command.yaml` files with either:
- a `.txt` template body
- a `.py` script hook
Commands are managed from the plugin modal and can be inserted directly from the chat composer when the first token starts with `/`.
## Features
- `.command.yaml` config files with command metadata
- Text template commands with `{}` placeholders and parsed args
- Python hook commands with parsed args and optional chat history payload
- Unified parser for positional args, free-form tail, and flags
- Scope-aware command resolution across project and global scopes
- Built-in A0 CLI connector command pack for common session, queue, model, project, browser, and connector status commands
- Slash picker in the chat composer with keyboard navigation and create-on-empty flow
## Command File Model
Each command is defined by one config file plus one content file in the same scope directory.
Set `webui_hidden: true` to keep a command resolvable while omitting it from the chat composer picker.
Example text command:
`scan.command.yaml`
```yaml
name: scan
description: Scan a Git repository.
argument_hint: /scan --git-url https://github.com/org/repo
type: text
template_path: scan.txt
```
`scan.txt`
```txt
Please scan repository: {args.flags.git_url}
Raw input:
{raw}
```
Example python hook command:
`optimize.command.yaml`
```yaml
name: optimize
description: Optimize the current request.
argument_hint: /optimize 30%
type: script
script_path: optimize.py
include_history: true
```
`optimize.py`
```python
def run(payload):
args = payload["arguments"]
pct = args["positional"][0] if args["positional"] else "10%"
return {
"text": f"Optimize this response by {pct}.",
"effects": [],
}
```
## Argument Parsing
The parser supports:
- Positional input: `/scan https://github.com/org/repo`
- Long flags: `/scan --git-url https://github.com/org/repo`
- Long flags with equals: `/scan --git-url=https://github.com/org/repo`
- Short flags and bundles: `/scan -v -q` or `/scan -vq`
Parsed data is available to:
- Text templates via `{}` placeholders:
- `{raw}`
- `{args.positional.0}`
- `{args.flags.git_url}`
- Python scripts via `payload["arguments"]`
## Script Hook Contract
Python hook file must expose:
```python
def run(payload): ...
```
It can return:
- `str` (used as replacement text)
- `dict` with:
- `text: str` (replacement text)
- `effects: list[dict]`
Supported frontend effects:
- `{"type": "replace_input", "text": "..."}`
- `{"type": "append_input", "text": "..."}`
- `{"type": "toast", "level": "info|error|success", "message": "..."}`
- Built-in UI effects for existing WebUI actions such as chat switching, modals, attachments, compaction, queue actions, transcript copy, and toast output
## Scope Resolution
Commands are discovered from these scope folders:
- Project: `usr/projects/<project>/.a0proj/plugins/_commands/commands/`
- Global fallback: `usr/plugins/_commands/commands/`
- Built-in defaults: `plugins/_commands/commands/`
- Other enabled plugins: `plugins/<plugin>/commands/` or `usr/plugins/<plugin>/commands/`
Precedence in the chat picker:
1. Project
2. Global
3. Built-in `_commands`
4. Other plugin-distributed commands
## Legacy Community Plugin Migration
When the built-in `_commands` plugin starts, it migrates files from the older community `commands` plugin namespace:
- Copies `usr/plugins/commands/commands/` into `usr/plugins/_commands/commands/`
- Copies `usr/plugins/commands/skills/` into `usr/plugins/_commands/skills/`
- Copies project and agent scoped `plugins/commands/commands/` folders to matching `plugins/_commands/commands/` folders
- Skips existing destination files
- Disables the legacy `commands` plugin roots so the WebUI does not load two slash-command popovers
## UI Surfaces
- Plugin modal: open the Commands manager from the Plugins dialog
- Sidebar quick action: terminal icon next to the Plugins button
- Chat composer: type `/` at the start of the inline input to browse commands
## Agent Skill
The plugin ships with `commands-create-slash-command`, a plugin-scoped skill that helps Agent Zero create or update command files.

View file

@ -0,0 +1 @@
"""Commands plugin package."""

View file

@ -0,0 +1,165 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._commands.helpers import commands as commands_helper
class Commands(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
action = str(input.get("action", "") or "").strip()
if action == "list_effective":
return self._list_effective(input)
if action == "list_scope":
return self._list_scope(input)
if action == "get":
return self._get(input)
if action == "save":
return self._save(input)
if action == "delete":
return self._delete(input)
if action == "duplicate":
return self._duplicate(input)
if action == "scope_info":
return self._scope_info(input)
if action == "resolve":
return await self._resolve(input)
return Response(status=400, response=f"Unknown action: {action}")
def _list_effective(self, input: dict) -> dict | Response:
context_scope = commands_helper.get_context_scope(str(input.get("context_id", "") or ""))
commands, scope = commands_helper.list_effective_commands(
project_name=context_scope["project_name"],
)
commands = [
command
for command in commands
if not command.get("frontmatter_extra", {}).get("webui_hidden")
]
return {
"ok": True,
"commands": commands,
"scope": scope,
}
def _list_scope(self, input: dict) -> dict | Response:
commands, scope = commands_helper.list_scope_commands(
project_name=str(input.get("project_name", "") or ""),
)
return {
"ok": True,
"commands": commands,
"scope": scope,
}
def _get(self, input: dict) -> dict | Response:
path = str(input.get("path", "") or "")
if not path:
return Response(status=400, response="Missing path")
try:
command = commands_helper.get_command(
path,
project_name=str(input.get("project_name", "") or ""),
)
except FileNotFoundError:
return Response(status=404, response="Command not found")
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True, "command": command}
def _save(self, input: dict) -> dict | Response:
try:
command = commands_helper.save_command(
project_name=str(input.get("project_name", "") or ""),
existing_path=str(input.get("existing_path", "") or ""),
name=str(input.get("name", "") or ""),
description=str(input.get("description", "") or ""),
argument_hint=str(input.get("argument_hint", "") or ""),
command_type=str(input.get("command_type", "text") or "text"),
body=str(input.get("body", "") or ""),
include_history=bool(input.get("include_history", False)),
extra_frontmatter=input.get("extra_frontmatter", {}) or {},
)
except FileExistsError as error:
return Response(status=409, response=str(error))
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True, "command": command}
def _delete(self, input: dict) -> dict | Response:
path = str(input.get("path", "") or "")
if not path:
return Response(status=400, response="Missing path")
try:
commands_helper.delete_command(
path,
project_name=str(input.get("project_name", "") or ""),
)
except FileNotFoundError:
return Response(status=404, response="Command not found")
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True}
def _duplicate(self, input: dict) -> dict | Response:
path = str(input.get("path", "") or "")
if not path:
return Response(status=400, response="Missing path")
try:
command = commands_helper.duplicate_command(
path,
project_name=str(input.get("project_name", "") or ""),
)
except FileNotFoundError:
return Response(status=404, response="Command not found")
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True, "command": command}
def _scope_info(self, input: dict) -> dict | Response:
explicit_project = str(input.get("project_name", "") or "")
context_scope = commands_helper.get_context_scope(str(input.get("context_id", "") or ""))
project_name = explicit_project if "project_name" in input else context_scope["project_name"]
scope = commands_helper.get_scope_payload(
project_name=project_name,
ensure_directory=bool(input.get("ensure_directory", False)),
)
return {
"ok": True,
"scope": commands_helper.strip_private_scope(scope),
"context_scope": context_scope,
}
async def _resolve(self, input: dict) -> dict | Response:
path = str(input.get("path", "") or "")
if not path:
return Response(status=400, response="Missing path")
slash_text = str(input.get("slash_text", "") or "")
if not slash_text:
return Response(status=400, response="Missing slash_text")
try:
resolution = await commands_helper.resolve_command_invocation(
path=path,
slash_text=slash_text,
project_name=str(input.get("project_name", "") or ""),
context_id=str(input.get("context_id", "") or ""),
)
except FileNotFoundError:
return Response(status=404, response="Command not found")
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True, "resolution": resolution}

View file

@ -0,0 +1,5 @@
name: attach
description: Attach local image file(s) to the next message.
argument_hint: Choose local file(s) from the WebUI picker.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: browser
description: Choose Browser host/container mode and manage host-browser control.
argument_hint: "[host|container|status]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: chat
description: Switch to a chat context by id.
argument_hint: "<context_id>"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,6 @@
name: chats
description: List previous chats (default sorted by last updated). Use --project to filter by active project.
argument_hint: "[--project|--all-projects] [--sort=updated|created|name]"
type: script
script_path: connector_commands.py
webui_hidden: true

View file

@ -0,0 +1,4 @@
name: clear
description: Clear the visible chat log.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: compact
description: Open the connector-backed compaction confirmation flow.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: computer-use
description: Turn local Computer Use on or off.
argument_hint: "[on|off|status]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,305 @@
from __future__ import annotations
from typing import Any
from agent import AgentContext
from helpers import message_queue as mq
from helpers import plugins, projects
from helpers.integration_commands import try_handle_command
from helpers.state_monitor_integration import mark_dirty_for_context
CLI_ONLY = {
"quit": "Quit is an A0 CLI shell command. Close this browser tab or stop the WebUI session when you are done.",
}
def run(payload: dict[str, Any]) -> dict[str, Any]:
invocation = payload.get("invocation") or {}
raw_name = str(invocation.get("command_name") or "").strip().lower()
command = raw_name
raw_args = str(invocation.get("raw_arguments") or "").strip()
arguments = invocation.get("arguments") if isinstance(invocation.get("arguments"), dict) else {}
context_id = str((payload.get("context") or {}).get("context_id") or "").strip()
context = _context(context_id)
if command == "new":
return _effects(_toast("Created a new chat."), {"type": "new_chat"})
if command == "chat":
return _handle_chat(arguments)
if command == "chats":
return _show_markdown("Chats", _chat_list(context, arguments))
if command == "clear":
return _effects(_toast("Visible transcript cleared."), {"type": "clear_transcript"})
if command == "project":
return _handle_project(context, raw_args)
if command == "profile":
return _handle_profile(context, raw_args)
if command == "plugins":
return _effects({"type": "open_modal", "path": "/components/plugins/list/plugin-list.html"})
if command == "compact":
return _effects({"type": "compact_chat"})
if command == "pause":
return _effects(_toast("Pause requested."), {"type": "pause_agent", "paused": True})
if command == "resume":
return _effects(_toast("Resume requested."), {"type": "pause_agent", "paused": False})
if command == "nudge":
return _effects(_toast("Nudge sent."), {"type": "nudge_agent"})
if command == "send":
return _handle_queue(context, ["send"])
if command == "queue":
return _handle_queue(context, list(arguments.get("tokens") or []))
if command == "presets":
return _effects({"type": "open_modal", "path": "/plugins/_model_config/webui/main.html"})
if command == "models":
return _handle_models(context, raw_args)
if command == "browser":
return _handle_browser(context, raw_args)
if command == "attach":
return _effects({"type": "attach_files"})
if command == "computer-use":
return _show_markdown("Computer Use", _computer_use_status(context_id, raw_args))
if command == "copy":
return _effects({"type": "copy_transcript"})
if command == "status":
return _show_markdown("Status", _status(context))
if command in CLI_ONLY:
return _effects(_toast(CLI_ONLY[command], level="info"))
return _effects(_toast(f"Unknown command: /{raw_name or command}", level="error"))
def _context(context_id: str) -> AgentContext | None:
if context_id:
return AgentContext.get(context_id)
return AgentContext.current() or AgentContext.first()
def _require_context(context: AgentContext | None) -> str | None:
if context:
return None
return "Open or create a chat context first."
def _effects(*effects: dict[str, Any]) -> dict[str, Any]:
return {"text": "", "effects": [effect for effect in effects if effect]}
def _toast(message: str, *, level: str = "success") -> dict[str, Any]:
return {"type": "toast", "message": message, "level": level}
def _show_markdown(title: str, content: str) -> dict[str, Any]:
return _effects({"type": "show_markdown", "title": title, "content": content})
def _handle_chat(arguments: dict[str, Any]) -> dict[str, Any]:
selector = str((arguments.get("positional") or [""])[0] or "").strip()
if not selector:
return _effects(_toast("Usage: /chat <context_id>", level="error"))
if not AgentContext.get(selector):
return _effects(_toast(f"Chat context '{selector}' was not found.", level="error"))
return _effects(_toast(f"Switched to {selector}."), {"type": "select_chat", "context_id": selector})
def _handle_project(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
if not raw_args:
return _effects({"type": "open_modal", "path": "/components/projects/project-list.html"})
error = _require_context(context)
if error:
return _effects(_toast(error, level="error"))
return _show_markdown("Project", try_handle_command(context, f"/project {raw_args}") or "")
def _handle_profile(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
if not raw_args:
return _effects({"type": "open_modal", "path": "/components/settings/settings.html"})
error = _require_context(context)
if error:
return _effects(_toast(error, level="error"))
return _show_markdown("Agent Profile", try_handle_command(context, f"/agent {raw_args}") or "")
def _handle_models(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
return _effects({"type": "open_plugin_config", "plugin": "_model_config"})
def _handle_browser(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
args = raw_args.strip().lower().replace("-", "_").split()
action = args[0] if args else ""
if not action:
return _effects({"type": "open_modal", "path": "/plugins/_browser/webui/main.html"})
if action in {"status", "state"}:
return _show_markdown("Browser", _browser_status(context))
if action not in {"host", "container", "docker"}:
return _effects(_toast("Usage: /browser [host|container|status]", level="error"))
project_name = projects.get_context_project_name(context) if context else ""
settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {}
settings["runtime_backend"] = "host_required" if action == "host" else "container"
plugins.save_plugin_config("_browser", project_name or "", "", settings)
if context:
mark_dirty_for_context(context.id, reason="plugins._commands.browser_runtime")
label = "Host browser through A0 CLI" if settings["runtime_backend"] == "host_required" else "Internal Docker browser"
return _effects(_toast(f"Browser runtime set to {label}."))
def _browser_status(context: AgentContext | None) -> str:
project_name = projects.get_context_project_name(context) if context else ""
settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {}
runtime = str(settings.get("runtime_backend") or "container")
label = "Host browser through A0 CLI" if runtime == "host_required" else "Internal Docker browser"
return f"Browser runtime: {label}\n\nUse `/browser host` or `/browser container` to switch."
def _handle_queue(context: AgentContext | None, tokens: list[str]) -> dict[str, Any]:
error = _require_context(context)
if error:
return _effects(_toast(error, level="error"))
queue = mq.get_queue(context)
if not tokens:
return _show_markdown("Queue", _queue_summary(queue))
action = str(tokens[0] or "").lower()
if action in {"send", "all", "flush"}:
if not queue:
return _effects(_toast("No queued messages."))
sent_count = mq.send_all_aggregated(context)
mark_dirty_for_context(context.id, reason="plugins._commands.queue_send")
noun = "message" if sent_count == 1 else "messages"
return _effects(_toast(f"Sent {sent_count} queued {noun}."))
if action in {"clear", "delete"} and len(tokens) == 1:
mq.remove(context)
mark_dirty_for_context(context.id, reason="plugins._commands.queue_clear")
return _effects(_toast("Queue cleared."))
if action in {"remove", "rm", "delete"}:
if len(tokens) < 2:
return _effects(_toast("Usage: /queue remove <number|id>", level="error"))
item_id = _queue_selector_to_id(queue, str(tokens[1]))
if not item_id:
return _effects(_toast(f"No queued message matches '{tokens[1]}'.", level="error"))
mq.remove(context, item_id)
mark_dirty_for_context(context.id, reason="plugins._commands.queue_remove")
return _effects(_toast("Queued message removed."))
return _effects(_toast("Usage: /queue [send|clear|remove <number|id>]", level="error"))
def _queue_summary(queue: list[dict[str, Any]]) -> str:
if not queue:
return "No queued messages."
lines = [f"Queued messages ({len(queue)}):"]
for index, item in enumerate(queue, start=1):
text = str(item.get("text") or "").strip() or "(attachment only)"
if len(text) > 100:
text = text[:97].rstrip() + "..."
attachments = item.get("attachments") or []
suffix = f" [{len(attachments)} files]" if attachments else ""
lines.append(f"{index}. {text}{suffix}")
return "\n".join(lines)
def _queue_selector_to_id(queue: list[dict[str, Any]], selector: str) -> str:
value = selector.strip()
if value.isdigit():
index = int(value) - 1
if 0 <= index < len(queue):
return str(queue[index].get("id") or "")
return ""
return value
def _chat_list(context: AgentContext | None, arguments: dict[str, Any]) -> str:
items = list(AgentContext.all())
flags = arguments.get("flags") or {}
active_project_only = bool(flags.get("project") or flags.get("active_project") or flags.get("p"))
sort_by = str(flags.get("sort") or "").lower()
positional = [str(item).lower() for item in (arguments.get("positional") or [])]
if not sort_by:
sort_by = next((item for item in positional if item in {"updated", "created", "name"}), "updated")
if sort_by not in {"updated", "created", "name"}:
return "Usage: /chats [--project|--all-projects] [--sort=updated|created|name]"
if active_project_only and context:
project_name = projects.get_context_project_name(context) or ""
items = [item for item in items if (projects.get_context_project_name(item) or "") == project_name]
def sort_key(item: AgentContext) -> Any:
output = item.output()
if sort_by == "name":
return (item.name or item.id).casefold()
if sort_by == "created":
return str(output.get("created_at") or "")
return str(output.get("last_message") or output.get("created_at") or "")
items = sorted(items, key=sort_key, reverse=sort_by != "name")
if not items:
return "No chats found."
lines = ["| Chat | Context | State |", "| --- | --- | --- |"]
for item in items[:30]:
marker = "current" if context and item.id == context.id else ("running" if item.is_running() else "idle")
lines.append(f"| {_escape_cell(item.name or item.id)} | `{item.id}` | {marker} |")
if len(items) > 30:
lines.append(f"\nShowing 30 of {len(items)} chats.")
return "\n".join(lines)
def _status(context: AgentContext | None) -> str:
error = _require_context(context)
if error:
return error
project_name = projects.get_context_project_name(context) or "none"
profile = getattr(context.agent0.config, "profile", "default") if context.agent0 else "default"
running = "running" if context.is_running() else "idle"
if getattr(context, "paused", False):
running = "paused"
return "\n".join(
[
f"Context: `{context.id}`",
f"State: {running}",
f"Project: {project_name}",
f"Agent profile: {profile}",
f"Queued messages: {len(mq.get_queue(context))}",
]
)
def _computer_use_status(context_id: str, raw_args: str) -> str:
from plugins._a0_connector.helpers import ws_runtime
action = "-".join(part.strip().lower().replace("_", "-") for part in raw_args.split()) or "status"
sids = ws_runtime.remote_tool_sids_for_context(context_id) if context_id else sorted(ws_runtime.connected_sids())
if not sids:
return (
"No A0 CLI is connected to this WebUI session.\n\n"
"Computer Use requires the CLI because the desktop permission prompt and native backend live on the CLI host. "
"Start A0 CLI, connect it to this Agent Zero instance, then run `/computer-use on` in the CLI."
)
if action in {"on", "off", "enable", "disable", "enabled", "disabled", "true", "false", "yes", "no", "1", "0"}:
return (
"Computer Use must be armed from the connected A0 CLI because it controls local desktop permissions.\n\n"
"Run `/computer-use on` or `/computer-use off` in the CLI terminal."
)
lines = ["Connected A0 CLI sessions:"]
for sid in sids:
metadata = ws_runtime.computer_use_metadata_for_sid(sid) or {}
if not metadata:
lines.append(f"- `{sid}`: connected, but not advertising Computer Use metadata.")
continue
state = "enabled" if metadata.get("enabled") else "disabled"
supported = "supported" if metadata.get("supported") else "unsupported"
status = str(metadata.get("status") or "unknown")
detail = str(metadata.get("last_error") or metadata.get("support_reason") or "").strip()
suffix = f" ({detail})" if detail else ""
lines.append(f"- `{sid}`: {state}, {supported}, status: {status}{suffix}")
lines.append("\nUse `/computer-use on|off|status` in the CLI to change local Computer Use.")
return "\n".join(lines)
def _escape_cell(value: str) -> str:
return str(value).replace("|", "\\|")

View file

@ -0,0 +1,4 @@
name: copy
description: Copy the currently visible transcript text to the clipboard.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: models
description: Open Main/Utility model runtime editor.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: new
description: Create a brand-new empty chat context.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: nudge
description: Nudge the current agent run.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: pause
description: Pause the active agent run.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: plugins
description: Open the installed-only Agent Zero plugin toggle view.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: presets
description: Open preset picker with Main/Utility model details.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: profile
description: Pick or set the active Agent Zero Core profile.
argument_hint: "[profile]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: project
description: Open the project menu, or switch directly with /project <name>.
argument_hint: "[name]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: queue
description: Show, send, clear, or remove queued messages.
argument_hint: "[send|clear|remove <number|id>]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: quit
description: Disconnect and exit the CLI.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: resume
description: Resume a paused agent run.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: send
description: Send all queued messages now.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: status
description: Show this chat's project, model, agent, and queue state.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,124 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from helpers import cache, files, plugins
from helpers.extension import Extension
from helpers.print_style import PrintStyle
LEGACY_PLUGIN_NAME = "commands"
PLUGIN_NAME = "_commands"
COMMANDS_DIR = "commands"
SKILLS_DIR = "skills"
class LegacyCommandsMigration(Extension):
def execute(self, **kwargs):
result = migrate_legacy_commands()
if result["copied_commands"] or result["copied_skills"] or result["disabled_roots"]:
PrintStyle.info("Migrated legacy commands plugin data:", result)
def migrate_legacy_commands(base_dir: str | Path | None = None) -> dict[str, Any]:
root = Path(base_dir or files.get_abs_path("")).resolve()
result: dict[str, Any] = {
"copied_commands": 0,
"copied_skills": 0,
"disabled_roots": 0,
}
legacy_plugin_dir = root / "usr" / "plugins" / LEGACY_PLUGIN_NAME
if not legacy_plugin_dir.exists() and not _legacy_scoped_plugin_dirs(root):
return result
for legacy_commands_dir in _legacy_command_dirs(root):
target = _replace_plugin_segment(legacy_commands_dir, PLUGIN_NAME)
result["copied_commands"] += _copy_tree_files(legacy_commands_dir, target)
result["copied_skills"] += _copy_tree_files(
legacy_plugin_dir / SKILLS_DIR,
root / "usr" / "plugins" / PLUGIN_NAME / SKILLS_DIR,
)
for legacy_plugin_root in _legacy_plugin_roots(root):
if _disable_legacy_plugin_root(legacy_plugin_root):
result["disabled_roots"] += 1
if result["disabled_roots"]:
_clear_runtime_caches()
return result
def _legacy_command_dirs(root: Path) -> list[Path]:
return [
plugin_root / COMMANDS_DIR
for plugin_root in _legacy_plugin_roots(root)
if (plugin_root / COMMANDS_DIR).is_dir()
]
def _legacy_plugin_roots(root: Path) -> list[Path]:
roots = []
for candidate in [
root / "usr" / "plugins" / LEGACY_PLUGIN_NAME,
*root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"),
*root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
*root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
]:
if candidate.exists() and candidate not in roots:
roots.append(candidate)
return roots
def _legacy_scoped_plugin_dirs(root: Path) -> list[Path]:
return [
*root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"),
*root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
*root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
]
def _replace_plugin_segment(path: Path, plugin_name: str) -> Path:
parts = list(path.parts)
for index in range(len(parts) - 1):
if parts[index] == "plugins" and parts[index + 1] == LEGACY_PLUGIN_NAME:
parts[index + 1] = plugin_name
return Path(*parts)
return path
def _copy_tree_files(source: Path, target: Path) -> int:
if not source.is_dir():
return 0
copied = 0
for source_file in source.rglob("*"):
if not source_file.is_file():
continue
relative_path = source_file.relative_to(source)
target_file = target / relative_path
if target_file.exists():
continue
target_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_file, target_file)
copied += 1
return copied
def _disable_legacy_plugin_root(plugin_root: Path) -> bool:
plugin_root.mkdir(parents=True, exist_ok=True)
enabled_file = plugin_root / plugins.ENABLED_FILE_NAME
disabled_file = plugin_root / plugins.DISABLED_FILE_NAME
changed = enabled_file.exists() or not disabled_file.exists()
enabled_file.unlink(missing_ok=True)
disabled_file.write_text("", encoding="utf-8")
return changed
def _clear_runtime_caches() -> None:
cache.clear("*(plugins)*")
cache.clear("*(extensions)*")

View file

@ -0,0 +1,192 @@
<html>
<head>
<script type="module">
import { store } from "/plugins/_commands/webui/commands-slash-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.commandsSlash">
<div class="commands-slash-menu-root" x-create="$store.commandsSlash.onMount()" x-destroy="$store.commandsSlash.cleanup()">
<div class="commands-slash-menu" x-show="$store.commandsSlash.menuVisible" x-transition.opacity.duration.120ms>
<template x-if="$store.commandsSlash.loading">
<div class="commands-slash-loading">
<span class="material-symbols-outlined spinning">progress_activity</span>
<span>Loading slash commands...</span>
</div>
</template>
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length > 0">
<div class="commands-slash-results">
<template x-for="(command, index) in $store.commandsSlash.filteredCommands" :key="command.path">
<button type="button"
class="commands-slash-item"
:class="{ active: index === $store.commandsSlash.selectedIndex }"
@mouseenter="$store.commandsSlash.selectedIndex = index"
@mousedown.prevent
@click.prevent="$store.commandsSlash.applySelection(command)">
<div class="commands-slash-item-header">
<div class="commands-slash-item-name">
<span class="commands-slash-prefix">/</span><span x-text="command.name"></span>
</div>
<span class="commands-slash-scope" x-text="command.source_scope_label"></span>
</div>
<div class="commands-slash-item-description" x-text="command.description"></div>
<template x-if="command.argument_hint">
<div class="commands-slash-item-hint" x-text="command.argument_hint"></div>
</template>
</button>
</template>
</div>
</template>
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length === 0">
<div class="commands-slash-empty">
<div class="commands-slash-empty-copy">
No matching slash commands.
</div>
<button type="button"
class="commands-slash-create"
@mousedown.prevent
@click.prevent="$store.commandsSlash.openCreateCommand()">
<span class="material-symbols-outlined">add</span>
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
</button>
</div>
</template>
</div>
</div>
</template>
</div>
<style>
.commands-slash-menu-root {
position: relative;
z-index: 30;
}
.commands-slash-menu {
margin-bottom: 0.55rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: color-mix(in srgb, var(--color-panel) 96%, var(--color-background));
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.14);
overflow: hidden;
}
.commands-slash-results {
max-height: 18rem;
overflow-y: auto;
}
.commands-slash-item {
display: flex;
flex-direction: column;
gap: 0.32rem;
width: 100%;
padding: var(--spacing-sm) 0.8rem;
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.commands-slash-item:last-child {
border-bottom: 0;
}
.commands-slash-item.active,
.commands-slash-item:hover {
background: color-mix(in srgb, var(--color-highlight) 10%, transparent);
}
.commands-slash-item-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.commands-slash-item-name {
font-weight: 600;
}
.commands-slash-prefix {
color: var(--color-highlight);
}
.commands-slash-scope {
padding: 0.18rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
font-size: 0.72rem;
font-weight: 600;
white-space: nowrap;
}
.commands-slash-item-description {
color: var(--color-text-secondary);
font-size: 0.86rem;
line-height: 1.4;
}
.commands-slash-item-hint {
color: var(--color-text-secondary);
font-family: "Roboto Mono", monospace;
font-size: 0.77rem;
opacity: 0.85;
}
.commands-slash-empty,
.commands-slash-loading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.9rem 1rem;
}
.commands-slash-empty-copy,
.commands-slash-loading {
color: var(--color-text-secondary);
}
.commands-slash-create {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: transparent;
color: var(--color-text);
cursor: pointer;
font-weight: 600;
white-space: nowrap;
}
.spinning {
animation: commands-slash-spin 1s linear infinite;
}
@keyframes commands-slash-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.commands-slash-empty,
.commands-slash-loading {
flex-direction: column;
align-items: stretch;
}
.commands-slash-create {
justify-content: center;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,9 @@
<div x-data>
<button x-move-after=".config-button#plugins"
class="config-button"
id="commands-plugin"
title="Commands"
@click="import('/plugins/_commands/webui/commands-store.js').then(({ store }) => store.openManager())">
<span class="material-symbols-outlined">terminal</span>
</button>
</div>

View file

@ -0,0 +1 @@
"""Helpers for the commands plugin."""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
name: _commands
title: Commands
description: YAML-configured slash commands with text templates or Python hooks.
version: 0.5.0
settings_sections: []
per_project_config: false
per_agent_config: false
always_enabled: false

View file

@ -0,0 +1,72 @@
---
name: commands-create-slash-command
description: Create or update Agent Zero slash commands for the built-in Commands plugin. Use when the user asks to add, edit, duplicate, or refine a reusable /command backed by YAML config plus text/python content files.
version: 1.0.0
tags: ["commands", "slash-commands", "plugin", "yaml", "python", "templates"]
triggers:
- create slash command
- add slash command
- update slash command
- edit slash command
- commands plugin
---
# Commands Plugin Slash Command Authoring
Use this skill when the user wants a reusable `/command` for Agent Zero's built-in `_commands` plugin.
## Source Of Truth
- Slash commands are file-backed, not database rows.
- Each command uses:
- one config file: `<slug>.command.yaml`
- one content file:
- text template: `<slug>.txt`, or
- python hook: `<slug>.py`
- Required config keys:
- `name`
- `description`
- `type` (`text` or `script`)
- Optional config keys:
- `argument_hint`
- `include_history` (script commands)
- Preserve unknown config keys when editing existing commands.
## Scope Resolution
Choose the target folder from the requested scope:
- Project: `usr/projects/<project>/.a0proj/plugins/_commands/commands/`
- Global fallback: `usr/plugins/_commands/commands/`
If the user does not specify a scope, prefer the active chat scope when it is clear. Otherwise use the global scope.
## File Rules
- Config file format: `<slug>.command.yaml`
- Slash command name should be lowercase and hyphenated, for example `explain-code`
- For text commands, keep the `.txt` template concise and directly reusable
- For script commands, implement `run(payload)` in the `.py` file
- If the command expects trailing input, use `{raw}`, `{args.positional.0}`, or `{args.flags.some_flag}`
Use the bundled templates in `template.command.yaml` and `template.command.txt` when creating a new text command from scratch.
## Editing Workflow
1. Determine scope and final slash command name.
2. Check whether a command file already exists in that scope.
3. If it exists, load the file first and preserve unknown frontmatter keys.
4. Update YAML config and template/script content.
5. Save the file in the correct scope folder.
6. Report:
- the saved config path
- the saved content path
- the slash command name in `/name` form
## Output Contract
After saving, explicitly state the final file path and the exact slash command invocation, for example:
- `Saved config: /a0/usr/plugins/_commands/commands/explain-code.command.yaml`
- `Saved content: /a0/usr/plugins/_commands/commands/explain-code.txt`
- `Invoke with: /explain-code`

View file

@ -0,0 +1,3 @@
Describe the work to perform here.
{raw}

View file

@ -0,0 +1,5 @@
name: example-command
description: Briefly describe what this slash command does.
argument_hint: Optional free-form text after /example-command
type: text
template_path: example-command.txt

View file

@ -0,0 +1,11 @@
# tests/conftest.py
import sys
from pathlib import Path
# Ensure A0 framework root is first in sys.path so that
# `from helpers import ...` resolves to A0's helpers/, not
# the Commands plugin's local helpers/ directory.
a0_root = str(Path(__file__).resolve().parents[3]) # tests/ → _commands → plugins → a0
while a0_root in sys.path:
sys.path.remove(a0_root)
sys.path.insert(0, a0_root)

View file

@ -0,0 +1,367 @@
from __future__ import annotations
import threading
import uuid
from dataclasses import dataclass, field
from pathlib import Path
import sys
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from agent import AgentContext
from helpers import files, projects, skills as skills_helper
from initialize import initialize_agent
from plugins._commands.api.commands import Commands
from plugins._commands.commands import connector_commands
from plugins._commands.helpers import commands as commands_helper
@dataclass
class ScopeFixture:
prefix: str
project_name: str
created_paths: list[str] = field(default_factory=list)
def _track_paths(scope: ScopeFixture, command: dict) -> dict:
for key in ("path", "config_path", "content_path"):
command_path = files.fix_dev_path(command.get(key, ""))
if command_path and command_path not in scope.created_paths:
scope.created_paths.append(command_path)
return command
def _save_command(
scope: ScopeFixture,
*,
project_name: str = "",
name: str,
description: str,
body: str = "",
argument_hint: str = "",
command_type: str = "text",
include_history: bool = False,
extra_frontmatter: dict | None = None,
) -> dict:
command = commands_helper.save_command(
project_name=project_name,
name=name,
description=description,
body=body,
argument_hint=argument_hint,
command_type=command_type,
include_history=include_history,
extra_frontmatter=extra_frontmatter or {},
)
return _track_paths(scope, command)
@pytest.fixture
def scope_fixture() -> ScopeFixture:
suffix = uuid.uuid4().hex[:8]
scope = ScopeFixture(
prefix=f"commands-test-{suffix}",
project_name=f"commands_project_{suffix}",
)
yield scope
for path in reversed(scope.created_paths):
files.delete_file(path)
files.delete_dir(files.get_abs_path("usr", "projects", scope.project_name))
def _new_handler() -> Commands:
app = Flask("commands_plugin_tests")
app.secret_key = "commands-plugin-tests"
return Commands(app, threading.RLock())
def test_command_config_and_template_files_round_trip(
scope_fixture: ScopeFixture,
) -> None:
command = _save_command(
scope_fixture,
name=f"Explain {scope_fixture.prefix}",
description="Explain a code sample clearly.",
body="Explain the sample.\n\n{raw}",
argument_hint="Paste code or describe the module.",
command_type="text",
extra_frontmatter={"category": "analysis", "audience": "team"},
)
config_path = Path(files.fix_dev_path(command["path"]))
content_path = Path(files.fix_dev_path(command["content_path"]))
assert config_path.name == f"explain-{scope_fixture.prefix}.command.yaml"
assert content_path.name == f"explain-{scope_fixture.prefix}.txt"
loaded = commands_helper.get_command(command["path"])
assert loaded["frontmatter_extra"] == {
"category": "analysis",
"audience": "team",
}
config_yaml = files.read_file(str(config_path))
assert "category: analysis" in config_yaml
assert "audience: team" in config_yaml
assert f"name: explain-{scope_fixture.prefix}" in config_yaml
assert "type: text" in config_yaml
template_text = files.read_file(str(content_path))
assert "Explain the sample." in template_text
def test_parse_arguments_and_render_template_support_flags() -> None:
parsed = commands_helper.parse_arguments(
'--git-url=https://github.com/acme/repo "quoted phrase" -v 30%'
)
assert parsed["flags"]["git_url"] == "https://github.com/acme/repo"
assert parsed["flags"]["v"] is True
assert parsed["positional"] == ["quoted phrase", "30%"]
invocation = commands_helper.parse_slash_invocation(
'/optimize 30% --mode fast --git-url=https://github.com/acme/repo'
)
rendered = commands_helper.render_text_template(
"Pct: {args.positional.0}\nMode: {args.flags.mode}\nURL: {args.flags.git_url}\nRaw: {raw}",
invocation,
)
assert rendered == (
"Pct: 30%\n"
"Mode: fast\n"
"URL: https://github.com/acme/repo\n"
"Raw: 30% --mode fast --git-url=https://github.com/acme/repo"
)
appended = commands_helper.render_text_template(
"Summarize this request.",
commands_helper.parse_slash_invocation("/summarize alpha beta"),
)
assert appended == "Summarize this request.\n\nArguments:\nalpha beta"
invalid_invocation = commands_helper.parse_slash_invocation("/?")
assert invalid_invocation["command_name"] == ""
def test_list_effective_commands_project_overrides_global(
scope_fixture: ScopeFixture,
) -> None:
shared_name = f"{scope_fixture.prefix}-shared"
_save_command(
scope_fixture,
name=shared_name,
description="global description",
body="global body",
command_type="text",
)
_save_command(
scope_fixture,
project_name=scope_fixture.project_name,
name=shared_name,
description="project description",
body="project body",
command_type="text",
)
project_commands, _ = commands_helper.list_effective_commands(
scope_fixture.project_name
)
global_commands, _ = commands_helper.list_effective_commands("")
assert {command["name"]: command for command in project_commands}[shared_name][
"description"
] == "project description"
assert {command["name"]: command for command in global_commands}[shared_name][
"description"
] == "global description"
scoped_commands, _ = commands_helper.list_scope_commands(scope_fixture.project_name)
scoped_command = next(
command for command in scoped_commands if command["name"] == shared_name
)
assert scoped_command["override_count"] == 1
assert scoped_command["override_scopes"] == ["Global"]
def test_models_command_always_opens_modal():
result = connector_commands.run(
{
"invocation": {
"command_name": "models",
"raw_arguments": "default",
},
"context": {"context_id": ""},
}
)
assert result == {
"text": "",
"effects": [{"type": "open_plugin_config", "plugin": "_model_config"}],
}
@pytest.mark.asyncio
async def test_commands_api_crud_and_resolve_text_and_script(
scope_fixture: ScopeFixture,
) -> None:
handler = _new_handler()
command_name = f"{scope_fixture.prefix}-context"
context = AgentContext(
config=initialize_agent({}),
set_current=True,
)
context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, scope_fixture.project_name)
try:
saved = await handler.process(
{
"action": "save",
"project_name": scope_fixture.project_name,
"name": command_name,
"description": "context override",
"command_type": "text",
"body": (
"Repo: {args.flags.git_url}\n"
"Mode: {args.flags.mode}\n"
"Raw: {raw}"
),
},
None,
)
assert isinstance(saved, dict)
assert saved["ok"] is True
saved_command = _track_paths(scope_fixture, saved["command"])
loaded = await handler.process(
{
"action": "get",
"project_name": scope_fixture.project_name,
"path": saved_command["path"],
},
None,
)
assert isinstance(loaded, dict)
assert loaded["command"]["description"] == "context override"
resolved_text = await handler.process(
{
"action": "resolve",
"project_name": scope_fixture.project_name,
"path": saved_command["path"],
"slash_text": f"/{command_name} --git-url=https://github.com/acme/repo --mode deep",
"context_id": context.id,
},
None,
)
assert isinstance(resolved_text, dict)
assert resolved_text["ok"] is True
rendered_text = resolved_text["resolution"]["result"]["text"]
assert "Repo: https://github.com/acme/repo" in rendered_text
assert "Mode: deep" in rendered_text
script_saved = await handler.process(
{
"action": "save",
"project_name": scope_fixture.project_name,
"name": f"{command_name}-script",
"description": "script command",
"command_type": "script",
"include_history": True,
"body": (
"def run(payload):\n"
" flags = payload['arguments'].get('flags', {})\n"
" return {\n"
" 'text': f\"Script mode: {flags.get('mode', 'none')}\",\n"
" 'effects': [\n"
" {'type': 'toast', 'level': 'success', 'message': 'Script executed'}\n"
" ],\n"
" }\n"
),
},
None,
)
assert isinstance(script_saved, dict)
assert script_saved["ok"] is True
script_command = _track_paths(scope_fixture, script_saved["command"])
resolved_script = await handler.process(
{
"action": "resolve",
"project_name": scope_fixture.project_name,
"path": script_command["path"],
"slash_text": f"/{command_name}-script --mode turbo",
"context_id": context.id,
},
None,
)
assert isinstance(resolved_script, dict)
assert resolved_script["ok"] is True
assert resolved_script["resolution"]["result"]["text"] == "Script mode: turbo"
assert resolved_script["resolution"]["result"]["effects"] == [
{
"type": "toast",
"level": "success",
"message": "Script executed",
}
]
duplicated = await handler.process(
{
"action": "duplicate",
"project_name": scope_fixture.project_name,
"path": saved_command["path"],
},
None,
)
assert isinstance(duplicated, dict)
assert duplicated["ok"] is True
assert duplicated["command"]["name"].startswith(f"{command_name}-copy")
duplicated_command = _track_paths(scope_fixture, duplicated["command"])
effective_list = await handler.process(
{"action": "list_effective", "context_id": context.id},
None,
)
assert isinstance(effective_list, dict)
effective_by_name = {
command["name"]: command for command in effective_list["commands"]
}
assert effective_by_name[command_name]["description"] == "context override"
assert effective_by_name[command_name]["source_scope_key"] == "project"
scope_info = await handler.process(
{"action": "scope_info", "context_id": context.id},
None,
)
assert isinstance(scope_info, dict)
assert scope_info["scope"]["project_name"] == scope_fixture.project_name
deleted = await handler.process(
{
"action": "delete",
"project_name": scope_fixture.project_name,
"path": duplicated_command["path"],
},
None,
)
assert isinstance(deleted, dict)
assert deleted["ok"] is True
finally:
AgentContext.remove(context.id)
AgentContext.set_current("")
def test_plugin_scoped_skill_is_discoverable() -> None:
skill = skills_helper.find_skill("commands-create-slash-command")
assert skill is not None
assert skill.skill_md_path.as_posix().endswith(
"plugins/_commands/skills/commands-create-slash-command/SKILL.md"
)

View file

@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from helpers import plugins
from plugins._commands.extensions.python.startup_migration._20_migrate_legacy_commands import (
migrate_legacy_commands,
)
def test_migrate_legacy_commands_copies_user_data_and_disables_old_plugin(tmp_path: Path):
legacy_root = tmp_path / "usr" / "plugins" / "commands"
legacy_commands = legacy_root / "commands"
legacy_skills = legacy_root / "skills" / "custom-skill"
project_commands = (
tmp_path
/ "usr"
/ "projects"
/ "demo"
/ ".a0proj"
/ "plugins"
/ "commands"
/ "commands"
)
new_commands = tmp_path / "usr" / "plugins" / "_commands" / "commands"
legacy_commands.mkdir(parents=True)
legacy_skills.mkdir(parents=True)
project_commands.mkdir(parents=True)
new_commands.mkdir(parents=True)
(legacy_root / "plugin.yaml").write_text("name: commands\n", encoding="utf-8")
(legacy_root / plugins.ENABLED_FILE_NAME).write_text("", encoding="utf-8")
(legacy_commands / "demo.command.yaml").write_text(
"name: demo\ndescription: Demo\ntype: text\ntemplate_path: demo.txt\n",
encoding="utf-8",
)
(legacy_commands / "demo.txt").write_text("legacy demo\n", encoding="utf-8")
(legacy_commands / "keep.command.yaml").write_text(
"name: keep\ndescription: Keep\ntype: text\ntemplate_path: keep.txt\n",
encoding="utf-8",
)
(new_commands / "keep.command.yaml").write_text("existing\n", encoding="utf-8")
(project_commands / "project.command.yaml").write_text(
"name: project\ndescription: Project\ntype: text\ntemplate_path: project.txt\n",
encoding="utf-8",
)
(legacy_skills / "SKILL.md").write_text("---\nname: custom-skill\n---\n", encoding="utf-8")
result = migrate_legacy_commands(tmp_path)
assert result["copied_commands"] == 3
assert result["copied_skills"] == 1
assert result["disabled_roots"] == 2
assert (new_commands / "demo.command.yaml").read_text(encoding="utf-8").startswith(
"name: demo"
)
assert (new_commands / "demo.txt").read_text(encoding="utf-8") == "legacy demo\n"
assert (new_commands / "keep.command.yaml").read_text(encoding="utf-8") == "existing\n"
assert (
tmp_path
/ "usr"
/ "projects"
/ "demo"
/ ".a0proj"
/ "plugins"
/ "_commands"
/ "commands"
/ "project.command.yaml"
).exists()
assert (
tmp_path
/ "usr"
/ "plugins"
/ "_commands"
/ "skills"
/ "custom-skill"
/ "SKILL.md"
).exists()
assert not (legacy_root / plugins.ENABLED_FILE_NAME).exists()
assert (legacy_root / plugins.DISABLED_FILE_NAME).exists()
assert (
tmp_path
/ "usr"
/ "projects"
/ "demo"
/ ".a0proj"
/ "plugins"
/ "commands"
/ plugins.DISABLED_FILE_NAME
).exists()

View file

@ -0,0 +1,337 @@
from __future__ import annotations
import os
import shutil
import uuid
from pathlib import Path
import sys
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from helpers import cache, files, plugins
from plugins._commands.api.commands import Commands
from plugins._commands.helpers import commands as commands_helper
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _clear_plugin_cache():
"""Ensure plugin list cache is fresh for every test."""
cache.clear("*(plugins)*")
yield
cache.clear("*(plugins)*")
@pytest.fixture()
def fake_plugin():
"""Create a temporary plugin in usr/plugins/ with a commands/ directory."""
suffix = uuid.uuid4().hex[:8]
plugin_name = f"_test_cmd_disc_{suffix}"
plugin_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name)
commands_dir = os.path.join(plugin_dir, "commands")
os.makedirs(commands_dir, exist_ok=True)
# Write plugin.yaml so the plugin is discoverable
files.write_file(
os.path.join(plugin_dir, "plugin.yaml"),
f"name: {plugin_name}\ntitle: Test\ndescription: Test\n",
)
yield {"name": plugin_name, "dir": plugin_dir, "commands_dir": commands_dir}
# Cleanup
shutil.rmtree(plugin_dir, ignore_errors=True)
cache.remove(plugins.PLUGINS_LIST_CACHE_AREA, "")
def _write_plugin_command(
fake_plugin: dict,
*,
name: str,
description: str,
body: str = "default body",
) -> str:
"""Write a .command.yaml + .txt into the fake plugin's commands/ dir.
Returns the config file path.
"""
slug = commands_helper.sanitize_command_name(name)
cdir = fake_plugin["commands_dir"]
config_path = os.path.join(cdir, f"{slug}.command.yaml")
content_path = os.path.join(cdir, f"{slug}.txt")
files.write_file(
config_path,
f"name: {slug}\ndescription: {description}\ntype: text\ntemplate_path: {slug}.txt\n",
)
files.write_file(content_path, body)
return config_path
# ── Tests ─────────────────────────────────────────────────────────────────────
def test_discover_plugin_commands_finds_plugin_commands(fake_plugin: dict):
"""Plugin commands must be discovered without relying on real installs."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-build",
description="build test",
)
discovered = commands_helper._discover_plugin_commands()
names = {c["name"] for c in discovered}
expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-build")
assert expected in names, f"Expected {expected!r} in discovered names, got {names}"
for cmd in discovered:
if cmd["name"] == expected:
assert cmd["source_plugin"] == fake_plugin["name"]
assert cmd["scope_key"] == "plugin"
assert cmd["scope_label"] == f"Plugin: {fake_plugin['name']}"
assert cmd["source_scope_key"] == "plugin"
assert cmd["source_scope_label"] == f"Plugin: {fake_plugin['name']}"
break
def test_discover_plugin_commands_skips_own_plugin():
"""The commands plugin itself must NOT appear in _discover_plugin_commands."""
discovered = commands_helper._discover_plugin_commands()
for cmd in discovered:
assert cmd.get("source_plugin") != "_commands"
def test_discover_plugin_commands_skips_disabled_plugins(fake_plugin: dict):
"""Disabled plugins must not contribute slash commands to the picker."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-disabled",
description="disabled command",
)
files.write_file(os.path.join(fake_plugin["dir"], plugins.DISABLED_FILE_NAME), "")
cache.clear("*(plugins)*")
discovered = commands_helper._discover_plugin_commands()
names = {command["name"] for command in discovered}
expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-disabled")
assert expected not in names
def test_discover_builtin_commands_marks_own_commands_read_only():
"""Bundled _commands command files are discoverable as built-ins, not plugin commands."""
discovered = commands_helper._discover_builtin_commands()
command = next((cmd for cmd in discovered if cmd["name"] == "new"), None)
assert command is not None
assert command["source_plugin"] == "_commands"
assert command["scope_key"] == "builtin"
assert command["scope_label"] == "Built-in"
loaded = commands_helper.get_command(command["path"])
assert loaded["name"] == "new"
assert loaded["scope_key"] == "builtin"
with pytest.raises(ValueError, match="Built-in commands are read-only"):
commands_helper.save_command(
existing_path=command["path"],
name="new",
description="updated description",
body="updated body",
)
with pytest.raises(ValueError, match="Built-in commands are read-only"):
commands_helper.delete_command(command["path"])
def test_builtin_commands_use_canonical_names_only():
discovered = commands_helper._discover_builtin_commands()
names = {command["name"] for command in discovered}
assert {"attach", "computer-use", "models", "plugins", "project"} <= names
assert {
"computer",
"cu",
"disconnect",
"exit",
"help",
"image",
"img",
"keys",
"model",
"plugin",
"projects",
}.isdisjoint(names)
def test_webui_effective_list_hides_webui_hidden_commands():
effective, _ = commands_helper.list_effective_commands("")
chats = next(command for command in effective if command["name"] == "chats")
response = object.__new__(Commands)._list_effective({"context_id": ""})
names = {command["name"] for command in response["commands"]}
assert chats["frontmatter_extra"]["webui_hidden"] is True
assert "chats" not in names
def test_list_effective_includes_plugin_commands(fake_plugin: dict):
"""list_effective_commands must include commands from other plugins."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-effective",
description="effective test",
)
effective, _ = commands_helper.list_effective_commands("")
expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-effective")
command = next((item for item in effective if item["name"] == expected), None)
assert command is not None
assert command["scope_key"] == "plugin"
assert command["scope_label"] == f"Plugin: {fake_plugin['name']}"
def test_plugin_commands_appear_in_effective_list(fake_plugin: dict):
"""Commands from a freshly-created plugin appear in effective list."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-hello",
description="A test command from a plugin",
body="Hello from plugin",
)
effective, _ = commands_helper.list_effective_commands("")
expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-hello")
command = next((item for item in effective if item["name"] == expected), None)
assert command is not None
assert command["source_plugin"] == fake_plugin["name"]
def test_precedence_global_overrides_plugin(fake_plugin: dict):
"""A global command with the same name takes precedence over a plugin command."""
shared_name = f"{fake_plugin['name']}-shared"
slug = commands_helper.sanitize_command_name(shared_name)
# 1. Plugin command (lowest precedence)
_write_plugin_command(
fake_plugin,
name=shared_name,
description="plugin version",
body="plugin body",
)
# 2. Global command (higher precedence)
try:
commands_helper.save_command(
name=shared_name,
description="global version",
body="global body",
)
effective, _ = commands_helper.list_effective_commands("")
by_name = {c["name"]: c for c in effective}
assert slug in by_name
assert by_name[slug]["description"] == "global version"
finally:
scope_dir = commands_helper.get_scope_directory("")
files.delete_file(os.path.join(scope_dir, f"{slug}.command.yaml"))
files.delete_file(os.path.join(scope_dir, f"{slug}.txt"))
def test_source_plugin_field_on_discovered_command(fake_plugin: dict):
"""Discovered commands must carry the source_plugin field."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-src-test",
description="source plugin test",
)
discovered = commands_helper._discover_plugin_commands()
slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-src-test")
match = next((c for c in discovered if c["name"] == slug), None)
assert match is not None
assert match["source_plugin"] == fake_plugin["name"]
assert match["scope_key"] == "plugin"
assert match["scope_label"] == f"Plugin: {fake_plugin['name']}"
assert match["source_scope_key"] == "plugin"
assert match["source_scope_label"] == f"Plugin: {fake_plugin['name']}"
def test_is_plugin_commands_dir_recognises_plugin_path(fake_plugin: dict):
"""_is_plugin_commands_dir must return True for files inside plugin commands/ dirs."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-path-check",
description="path test",
)
slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-path-check")
config_path = os.path.join(fake_plugin["commands_dir"], f"{slug}.command.yaml")
normalized = commands_helper._normalize_client_path(config_path)
assert commands_helper._is_plugin_commands_dir(normalized) is True
def test_is_plugin_commands_dir_rejects_non_plugin_path(tmp_path: Path):
"""_is_plugin_commands_dir must return False for arbitrary paths."""
non_plugin_path = tmp_path / "not-a-plugin" / "commands" / "foo.txt"
assert commands_helper._is_plugin_commands_dir(str(non_plugin_path)) is False
def test_get_command_can_load_plugin_command(fake_plugin: dict):
"""get_command must work for commands inside plugin directories."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-loadable",
description="loadable test",
body="load me",
)
slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-loadable")
config_path = os.path.join(fake_plugin["commands_dir"], f"{slug}.command.yaml")
normalized = commands_helper._normalize_client_path(config_path)
command = commands_helper.get_command(normalized, project_name="demo-project")
assert command["name"] == slug
assert command["description"] == "loadable test"
assert command["body"] == "load me"
assert command["source_plugin"] == fake_plugin["name"]
assert command["scope_key"] == "plugin"
assert command["scope_label"] == f"Plugin: {fake_plugin['name']}"
assert command["source_scope_key"] == "plugin"
assert command["source_scope_label"] == f"Plugin: {fake_plugin['name']}"
def test_save_command_rejects_plugin_existing_path(fake_plugin: dict):
"""Editing a plugin command must fail because plugin commands are read-only."""
config_path = _write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-readonly-edit",
description="read-only test",
body="plugin body",
)
normalized = commands_helper._normalize_client_path(config_path)
with pytest.raises(ValueError, match="Plugin commands are read-only"):
commands_helper.save_command(
existing_path=normalized,
name=f"{fake_plugin['name']}-readonly-edit",
description="updated description",
body="updated body",
)
def test_delete_command_rejects_plugin_command(fake_plugin: dict):
"""Deleting a plugin command must fail because plugin commands are read-only."""
config_path = _write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-readonly-delete",
description="read-only delete",
)
normalized = commands_helper._normalize_client_path(config_path)
with pytest.raises(ValueError, match="Plugin commands are read-only"):
commands_helper.delete_command(normalized)
assert os.path.exists(config_path)

View file

@ -0,0 +1,513 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as chatInputStore } from "/components/chat/input/input-store.js";
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
import {
toastFrontendError,
toastFrontendInfo,
toastFrontendSuccess,
} from "/components/notifications/notification-store.js";
import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js";
const COMMANDS_API_PATH = "/plugins/_commands/commands";
function sanitizeCommandName(rawName) {
return (rawName || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
}
function parseSlashInput(message) {
const text = String(message || "");
const match = text.match(/^\s*\/([^\s]*)(?:\s+([\s\S]*))?$/);
if (!match) {
return {
active: false,
query: "",
rawArguments: "",
rawMessage: text,
};
}
return {
active: true,
query: (match[1] || "").trim().toLowerCase(),
rawArguments: match[2] || "",
rawMessage: text,
};
}
function notifyError(message) {
void toastFrontendError(message, "Commands");
}
function notifySuccess(message) {
void toastFrontendSuccess(message, "Commands");
}
const HTML_ESCAPE = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value) {
return String(value || "").replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]);
}
function notifyInfo(title, message) {
const formatted = escapeHtml(message).replace(/\n/g, "<br>");
void toastFrontendInfo(formatted, title || "Commands", 3, "", undefined, true);
}
const model = {
loading: false,
applying: false,
commands: [],
contextScope: { project_name: "" },
lastContextId: "",
active: false,
dismissed: false,
query: "",
rawArguments: "",
rawMessage: "",
selectedIndex: 0,
boundInput: null,
keydownHandler: null,
inputHandler: null,
focusHandler: null,
commandsUpdatedHandler: null,
get menuVisible() {
return this.active && !this.dismissed;
},
get filteredCommands() {
const needle = (this.query || "").trim().toLowerCase();
const commands = Array.isArray(this.commands) ? this.commands : [];
if (!needle) return commands;
return commands.filter((command) => {
const haystack = `${command?.name || ""} ${command?.description || ""}`.toLowerCase();
return haystack.includes(needle);
});
},
get selectedCommand() {
const commands = this.filteredCommands;
if (!commands.length) return null;
return commands[this.selectedIndex] || commands[0] || null;
},
get emptyStateLabel() {
const name = sanitizeCommandName(this.query || "");
return name ? `Create /${name}` : "Create slash command";
},
onMount() {
this.ensureBindings();
this.keydownHandler = (event) => this.handleKeydown(event);
this.commandsUpdatedHandler = () => {
this.commands = [];
if (this.menuVisible) {
void this.loadCommands(true);
}
};
document.addEventListener("keydown", this.keydownHandler, true);
window.addEventListener("commands:updated", this.commandsUpdatedHandler);
this.handleInput();
},
cleanup() {
this.removeBindings();
if (this.keydownHandler) {
document.removeEventListener("keydown", this.keydownHandler, true);
}
if (this.commandsUpdatedHandler) {
window.removeEventListener("commands:updated", this.commandsUpdatedHandler);
}
this.keydownHandler = null;
this.commandsUpdatedHandler = null;
this.dismissed = false;
this.active = false;
this.query = "";
this.rawArguments = "";
this.rawMessage = "";
this.selectedIndex = 0;
this.applying = false;
},
ensureBindings() {
const input = this.getInputElement();
if (!input || input === this.boundInput) return;
this.removeBindings();
this.inputHandler = (event) => this.handleInput(event);
this.focusHandler = () => this.handleInput();
input.addEventListener("input", this.inputHandler);
input.addEventListener("focus", this.focusHandler);
this.boundInput = input;
},
removeBindings() {
if (this.boundInput && this.inputHandler) {
this.boundInput.removeEventListener("input", this.inputHandler);
}
if (this.boundInput && this.focusHandler) {
this.boundInput.removeEventListener("focus", this.focusHandler);
}
this.boundInput = null;
this.inputHandler = null;
this.focusHandler = null;
},
getInputElement() {
return document.getElementById("chat-input");
},
getInputMessage(event = null) {
const target = event?.target || null;
const targetEditor = target?.closest?.("#chat-input");
if (targetEditor?.isContentEditable || target?.isContentEditable) {
return (
chatInputStore?._editorToMarkdown?.() ||
targetEditor?.textContent ||
target?.textContent ||
""
);
}
if (typeof target?.value === "string") return target.value;
const input = this.getInputElement();
if (input?.isContentEditable) {
return chatInputStore?._editorToMarkdown?.() ?? input.textContent ?? "";
}
if (typeof input?.value === "string") return input.value;
return chatInputStore?.message ?? "";
},
getContextId() {
return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
},
async loadCommands(force = false) {
const contextId = this.getContextId();
if (!force && this.commands.length && contextId === this.lastContextId) {
this.ensureSelection();
return;
}
this.loading = true;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "list_effective",
context_id: contextId,
});
this.commands = Array.isArray(response?.commands) ? response.commands : [];
this.contextScope = response?.scope || {
project_name: "",
};
this.lastContextId = contextId;
this.ensureSelection();
} catch (error) {
console.error("Failed to load effective commands:", error);
this.commands = [];
this.contextScope = { project_name: "" };
} finally {
this.loading = false;
}
},
handleInput(event = null) {
this.ensureBindings();
this.dismissed = false;
const message = this.getInputMessage(event);
const parsed = parseSlashInput(message);
this.active = parsed.active;
this.query = parsed.query;
this.rawArguments = parsed.rawArguments;
this.rawMessage = parsed.rawMessage;
if (!this.active) {
this.selectedIndex = 0;
return;
}
this.ensureSelection();
void this.loadCommands();
},
handleKeydown(event) {
const input = this.getInputElement();
if (!this.menuVisible || !input || document.activeElement !== input) return;
if (event.isComposing || event.keyCode === 229) return;
if (event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
this.moveSelection(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
this.moveSelection(-1);
return;
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.dismissed = true;
return;
}
if (event.key === "Enter" && this.selectedCommand) {
event.preventDefault();
event.stopPropagation();
void this.applySelection(this.selectedCommand);
}
},
ensureSelection() {
const commands = this.filteredCommands;
if (!commands.length) {
this.selectedIndex = 0;
return;
}
if (this.selectedIndex >= commands.length) {
this.selectedIndex = 0;
}
},
moveSelection(delta) {
const commands = this.filteredCommands;
if (!commands.length) return;
const nextIndex =
(this.selectedIndex + delta + commands.length) % commands.length;
this.selectedIndex = nextIndex;
this.scrollSelectedIntoView();
},
scrollSelectedIntoView() {
requestAnimationFrame(() => {
document
.querySelector(".commands-slash-results .commands-slash-item.active")
?.scrollIntoView({ block: "nearest" });
});
},
async applySelection(command) {
if (!command || this.applying) return;
const input = this.getInputElement();
if (!input) return;
this.applying = true;
try {
const contextId = this.getContextId();
const fallbackSlash = this.rawMessage?.trim()
? this.rawMessage
: this.rawArguments
? `/${command.name} ${this.rawArguments}`
: `/${command.name}`;
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "resolve",
path: command.path,
slash_text: fallbackSlash,
project_name: this.contextScope?.project_name || "",
context_id: contextId,
});
const applied = await this.applyResolution(response?.resolution, input);
if (!applied?.hadToast && !applied?.hadError) {
notifySuccess(`Applied /${command.name}`);
}
} catch (error) {
console.error("Failed to apply slash command:", error);
notifyError(error?.message || "Failed to apply slash command.");
} finally {
this.applying = false;
}
},
async applyResolution(resolution, input) {
const result = resolution?.result || {};
const hasText = typeof result.text === "string";
let nextText = hasText ? result.text : this.getInputMessage();
const effects = Array.isArray(result.effects) ? result.effects : [];
let hadToast = false;
let hadError = false;
for (const effect of effects) {
if (!effect || typeof effect !== "object") continue;
const type = String(effect.type || "").trim().toLowerCase();
if (type === "replace_input") {
nextText = String(effect.text || "");
continue;
}
if (type === "append_input") {
const chunk = String(effect.text || "");
nextText = nextText ? `${nextText}\n${chunk}` : chunk;
continue;
}
if (type === "toast") {
hadToast = true;
const level = String(effect.level || "info").toLowerCase();
const message = String(effect.message || "");
if (!message) continue;
if (level === "error") {
hadError = true;
notifyError(message);
} else {
notifySuccess(message);
}
continue;
}
if (type === "new_chat") {
await chatsStore?.newChat?.();
continue;
}
if (type === "select_chat") {
const contextId = String(effect.context_id || "").trim();
if (contextId) await chatsStore?.selectChat?.(contextId);
continue;
}
if (type === "reset_chat") {
await chatsStore?.resetChat?.(String(effect.context_id || "") || null);
continue;
}
if (type === "pause_agent") {
await chatInputStore?.pauseAgent?.(Boolean(effect.paused));
continue;
}
if (type === "nudge_agent") {
await chatInputStore?.nudge?.();
continue;
}
if (type === "open_modal") {
const path = String(effect.path || "").trim();
if (path) await window.openModal?.(path);
continue;
}
if (type === "show_markdown") {
hadToast = true;
notifyInfo(
String(effect.title || "Slash Command"),
String(effect.content || ""),
);
continue;
}
if (type === "open_plugin_config") {
const pluginName = String(effect.plugin || "").trim();
if (pluginName) {
const { store } = await import("/components/plugins/plugin-settings-store.js");
await store.openConfig(
pluginName,
String(effect.project_name || ""),
String(effect.agent_profile || ""),
);
}
continue;
}
if (type === "compact_chat") {
const { store } = await import("/plugins/_chat_compaction/webui/compact-store.js");
await store.fetchStats();
continue;
}
if (type === "attach_files") {
await this.openAttachmentPicker(effect);
continue;
}
if (type === "copy_transcript") {
await this.copyTranscript();
hadToast = true;
continue;
}
if (type === "clear_transcript") {
const history = document.getElementById("chat-history");
if (history) history.innerHTML = "";
continue;
}
}
if (typeof input.value === "string") input.value = nextText;
chatInputStore.message = nextText;
input.dispatchEvent(new Event("input", { bubbles: true }));
chatInputStore.adjustTextareaHeight();
input.focus();
if (typeof input.setSelectionRange === "function") {
input.setSelectionRange(nextText.length, nextText.length);
} else {
chatInputStore?._setEditorCaret?.(nextText.length);
}
this.active = false;
this.dismissed = false;
this.query = "";
this.rawArguments = "";
this.rawMessage = nextText;
this.selectedIndex = 0;
return { hadToast, hadError };
},
openAttachmentPicker(effect = {}) {
return new Promise((resolve) => {
const picker = document.createElement("input");
let settled = false;
const done = () => {
if (settled) return;
settled = true;
picker.remove();
resolve();
};
picker.type = "file";
picker.multiple = true;
picker.accept = String(effect.accept || "*");
picker.style.display = "none";
picker.addEventListener("change", () => {
attachmentsStore?.handleFiles?.(picker.files || []);
done();
}, { once: true });
window.addEventListener("focus", () => setTimeout(done, 500), { once: true });
document.body.appendChild(picker);
picker.click();
});
},
async copyTranscript() {
const text = document.getElementById("chat-history")?.innerText?.trim() || "";
if (!text) {
notifyError("No visible transcript to copy.");
return;
}
await navigator.clipboard.writeText(text);
notifySuccess("Transcript copied.");
},
openCreateCommand() {
commandsManagerStore.openManager({
projectName: this.contextScope?.project_name || "",
prefillName: sanitizeCommandName(this.query || ""),
openEditor: true,
});
this.dismissed = true;
},
};
export const store = createStore("commandsSlash", model);

View file

@ -0,0 +1,417 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import {
toastFrontendError,
toastFrontendSuccess,
} from "/components/notifications/notification-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
const COMMANDS_API_PATH = "/plugins/_commands/commands";
const MAIN_MODAL_PATH = "/plugins/_commands/webui/main.html";
const EDITOR_MODAL_PATH = "/plugins/_commands/webui/editor.html";
function createEmptyEditor() {
return {
mode: "create",
existingPath: "",
path: "",
name: "",
description: "",
argumentHint: "",
commandType: "text",
includeHistory: false,
body: "",
extraFrontmatter: {},
};
}
function safeStringify(value) {
try {
return JSON.stringify(value ?? {});
} catch {
return "";
}
}
function sanitizeCommandName(rawName) {
return (rawName || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
}
function buildDefaultBody(commandType = "text") {
if (commandType === "script") {
return [
"def run(payload):",
" args = payload.get('arguments', {})",
" flags = args.get('flags', {})",
" positional = args.get('positional', [])",
" return {",
" 'text': f\"Script command received args: {positional} flags: {flags}\",",
" 'effects': [],",
" }",
"",
].join("\n");
}
return "Describe the work to perform here.\n\n{raw}";
}
function notifyError(message) {
void toastFrontendError(message, "Commands");
}
function notifySuccess(message) {
void toastFrontendSuccess(message, "Commands");
}
function emitCommandsUpdated() {
window.dispatchEvent(new CustomEvent("commands:updated"));
}
const model = {
loading: false,
saving: false,
projects: [],
projectName: "",
scope: null,
contextScope: { project_name: "" },
commands: [],
pendingScope: null,
pendingCreate: null,
editor: createEmptyEditor(),
editorSnapshot: "",
get selectedScopeLabel() {
return this.scope?.scope_label || "Global";
},
get selectedScopeDirectory() {
return this.scope?.directory_path || "";
},
get hasCommands() {
return (this.commands || []).length > 0;
},
get editorTitle() {
return this.editor.mode === "edit" ? "Edit Slash Command" : "Create Slash Command";
},
get editorDirty() {
return this._serializeEditor() !== this.editorSnapshot;
},
get editorBodyLabel() {
return this.editor.commandType === "script" ? "Python hook" : "Text template";
},
openManager(options = {}) {
const hasExplicitScope = Object.prototype.hasOwnProperty.call(options, "projectName");
this.pendingScope = hasExplicitScope
? {
projectName: options.projectName || "",
}
: null;
this.pendingCreate =
options.openEditor || options.prefillName
? {
name: options.prefillName || "",
}
: null;
return window.openModal?.(MAIN_MODAL_PATH);
},
async onOpen() {
await this.loadProjects();
try {
await this.resolveInitialScope();
await this.loadCommands();
} catch (error) {
console.error("Failed to initialize commands manager:", error);
this.scope = null;
this.commands = [];
notifyError(error?.message || "Failed to open the commands manager.");
}
if (this.pendingCreate) {
const pendingCreate = { ...this.pendingCreate };
this.pendingCreate = null;
await this.openCreateCommand({ name: pendingCreate.name });
}
},
cleanup() {
this.loading = false;
this.saving = false;
this.projects = [];
this.projectName = "";
this.scope = null;
this.contextScope = { project_name: "" };
this.commands = [];
this.pendingScope = null;
this.pendingCreate = null;
this.resetEditor();
},
async loadProjects() {
try {
const response = await callJsonApi("projects", { action: "list_options" });
this.projects = Array.isArray(response?.data) ? response.data : [];
} catch {
this.projects = [];
}
},
normalizeProject(projectName) {
if (!projectName) return "";
return (this.projects || []).some((project) => project?.key === projectName)
? projectName
: "";
},
async resolveInitialScope() {
const contextId =
chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
const scopeInfo = await callJsonApi(COMMANDS_API_PATH, {
action: "scope_info",
context_id: contextId,
});
this.contextScope = scopeInfo?.context_scope || {
project_name: "",
};
const preferredScope = this.pendingScope || scopeInfo?.scope || {};
this.projectName = this.normalizeProject(preferredScope.project_name || "");
this.pendingScope = null;
},
async loadCommands() {
this.loading = true;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "list_scope",
project_name: this.projectName || "",
});
this.commands = Array.isArray(response?.commands) ? response.commands : [];
this.scope = response?.scope || null;
} catch (error) {
console.error("Failed to load commands:", error);
this.commands = [];
this.scope = null;
notifyError(error?.message || "Failed to load commands.");
} finally {
this.loading = false;
}
},
async refresh() {
await this.loadCommands();
},
async onScopeChanged() {
this.projectName = this.normalizeProject(this.projectName);
await this.loadCommands();
},
overrideBadgeLabel(command) {
const count = Number(command?.override_count || 0);
if (!count) return "";
if (count === 1) {
return `Overrides ${command.override_scopes[0]}`;
}
return `Overrides ${count} lower scopes`;
},
async browseScopeFolder() {
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "scope_info",
project_name: this.projectName || "",
ensure_directory: true,
});
if (response?.scope?.directory_path) {
await fileBrowserStore.open(response.scope.directory_path);
}
} catch (error) {
console.error("Failed to open scope folder:", error);
notifyError(error?.message || "Failed to open scope folder.");
}
},
async openCreateCommand(options = {}) {
if (Object.prototype.hasOwnProperty.call(options, "projectName")) {
this.projectName = this.normalizeProject(options.projectName || "");
await this.loadCommands();
}
const suggestedName = sanitizeCommandName(options.name || "");
this.editor = {
...createEmptyEditor(),
mode: "create",
name: suggestedName,
commandType: "text",
body: buildDefaultBody("text"),
};
this.editorSnapshot = this._serializeEditor();
await this.openEditorModal();
},
async openEditCommand(command) {
if (!command?.path) return;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "get",
path: command.path,
project_name: this.projectName || "",
});
const loaded = response?.command || command;
this.editor = {
mode: "edit",
existingPath: loaded.path || "",
path: loaded.path || "",
name: loaded.name || "",
description: loaded.description || "",
argumentHint: loaded.argument_hint || "",
commandType: loaded.command_type || "text",
includeHistory: Boolean(loaded.include_history),
body: loaded.body || "",
extraFrontmatter: loaded.frontmatter_extra || {},
};
this.editorSnapshot = this._serializeEditor();
await this.openEditorModal();
} catch (error) {
console.error("Failed to load command:", error);
notifyError(error?.message || "Failed to load command.");
}
},
async duplicateCommand(command) {
if (!command?.path) return;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "duplicate",
path: command.path,
project_name: this.projectName || "",
});
await this.loadCommands();
emitCommandsUpdated();
notifySuccess(`Duplicated /${response?.command?.name || command.name}`);
if (response?.command) {
await this.openEditCommand(response.command);
}
} catch (error) {
console.error("Failed to duplicate command:", error);
notifyError(error?.message || "Failed to duplicate command.");
}
},
async deleteCommand(command) {
if (!command?.path) return;
try {
await callJsonApi(COMMANDS_API_PATH, {
action: "delete",
path: command.path,
project_name: this.projectName || "",
});
await this.loadCommands();
emitCommandsUpdated();
notifySuccess(`Deleted /${command.name}`);
} catch (error) {
console.error("Failed to delete command:", error);
notifyError(error?.message || "Failed to delete command.");
}
},
async openEditorModal() {
await window.openModal?.(EDITOR_MODAL_PATH, () => this.confirmCloseEditor());
this.resetEditor();
},
confirmCloseEditor() {
if (!this.editorDirty) return true;
return window.confirm("Discard unsaved slash command changes?");
},
async closeEditor() {
await window.closeModal?.(EDITOR_MODAL_PATH);
},
setEditorType(nextType) {
const normalizedType = nextType === "script" ? "script" : "text";
if (this.editor.commandType === normalizedType) return;
this.editor.commandType = normalizedType;
this.editor.includeHistory =
normalizedType === "script" ? this.editor.includeHistory : false;
this.editor.body = buildDefaultBody(normalizedType);
},
async saveEditor() {
this.saving = true;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "save",
project_name: this.projectName || "",
existing_path: this.editor.existingPath || "",
name: this.editor.name || "",
description: this.editor.description || "",
argument_hint: this.editor.argumentHint || "",
command_type: this.editor.commandType || "text",
include_history:
this.editor.commandType === "script" ? Boolean(this.editor.includeHistory) : false,
body: this.editor.body || "",
extra_frontmatter: this.editor.extraFrontmatter || {},
});
this.editor.path = response?.command?.path || "";
this.editor.existingPath = response?.command?.path || "";
this.editorSnapshot = this._serializeEditor();
await this.loadCommands();
emitCommandsUpdated();
notifySuccess(
`${this.editor.mode === "edit" ? "Updated" : "Saved"} /${response?.command?.name || this.editor.name}`,
);
await window.closeModal?.(EDITOR_MODAL_PATH);
} catch (error) {
console.error("Failed to save command:", error);
notifyError(error?.message || "Failed to save command.");
} finally {
this.saving = false;
}
},
resetEditor() {
this.editor = createEmptyEditor();
this.editorSnapshot = this._serializeEditor();
},
_serializeEditor() {
return safeStringify({
existingPath: this.editor.existingPath || "",
name: this.editor.name || "",
description: this.editor.description || "",
argumentHint: this.editor.argumentHint || "",
commandType: this.editor.commandType || "text",
includeHistory: Boolean(this.editor.includeHistory),
body: this.editor.body || "",
extraFrontmatter: this.editor.extraFrontmatter || {},
});
},
};
export const store = createStore("commandsManager", model);

View file

@ -0,0 +1,240 @@
<html>
<head>
<title>Slash Command</title>
<script type="module">
import { store } from "/plugins/_commands/webui/commands-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.commandsManager">
<div class="commands-editor"
x-create="$el.closest('.modal')?.querySelector('.modal-title') && ($el.closest('.modal').querySelector('.modal-title').textContent = $store.commandsManager.editorTitle)">
<div class="commands-editor-header">
<div class="commands-editor-copy">
<div class="commands-editor-title" x-text="$store.commandsManager.editorTitle"></div>
<div class="commands-editor-subtitle">
Saving writes a <code>.command.yaml</code> config plus a template file in <span x-text="$store.commandsManager.selectedScopeLabel"></span>.
</div>
</div>
</div>
<div class="commands-editor-grid">
<label class="commands-field">
<span>Command name</span>
<input type="text"
x-model="$store.commandsManager.editor.name"
placeholder="explain-code"
autocomplete="off">
</label>
<label class="commands-field">
<span>Description</span>
<input type="text"
x-model="$store.commandsManager.editor.description"
placeholder="Explain code clearly with examples.">
</label>
</div>
<label class="commands-field">
<span>Argument hint or example</span>
<input type="text"
x-model="$store.commandsManager.editor.argumentHint"
placeholder="Optional free-form text after /command">
</label>
<div class="commands-editor-grid">
<label class="commands-field">
<span>Command type</span>
<select x-model="$store.commandsManager.editor.commandType"
@change="$store.commandsManager.setEditorType($store.commandsManager.editor.commandType)">
<option value="text">Text template (.txt)</option>
<option value="script">Python hook (.py)</option>
</select>
</label>
<template x-if="$store.commandsManager.editor.commandType === 'script'">
<label class="commands-field commands-field-checkbox">
<span>Script context</span>
<div class="commands-checkbox-inline">
<input type="checkbox" x-model="$store.commandsManager.editor.includeHistory">
<span>Include chat history payload</span>
</div>
</label>
</template>
</div>
<label class="commands-field commands-field-body">
<span x-text="$store.commandsManager.editorBodyLabel"></span>
<textarea x-model="$store.commandsManager.editor.body"
rows="14"
:placeholder="$store.commandsManager.editor.commandType === 'script'
? 'Implement run(payload) and return { text, effects }.'
: 'Write reusable text. Use {raw}, {args.positional.0}, {args.flags.some_flag}.'"></textarea>
</label>
<template x-if="$store.commandsManager.editor.commandType === 'text'">
<div class="commands-editor-help">
<span class="commands-help-chip">{raw}</span>
<span class="commands-help-chip">{args.positional.0}</span>
<span class="commands-help-chip">{args.flags.git_url}</span>
<span class="commands-help-copy">
Trailing command input is parsed once and exposed to template placeholders and python payloads.
</span>
</div>
</template>
<template x-if="$store.commandsManager.editor.commandType === 'script'">
<div class="commands-editor-help">
<span class="commands-help-chip">run(payload)</span>
<span class="commands-help-chip">payload.arguments</span>
<span class="commands-help-chip">result.effects</span>
<span class="commands-help-copy">
Return a string or <code>{ text, effects }</code>. Effects support <code>replace_input</code>, <code>append_input</code>, and <code>toast</code>.
</span>
</div>
</template>
<template x-if="$store.commandsManager.editor.path">
<div class="commands-editor-path">
<span class="material-symbols-outlined">description</span>
<span x-text="$store.commandsManager.editor.path"></span>
</div>
</template>
</div>
</template>
</div>
<div class="modal-footer" data-modal-footer>
<button class="btn btn-ok"
@click="$store.commandsManager.saveEditor()"
:disabled="$store.commandsManager.saving">
Save
</button>
<button class="btn btn-cancel" @click="$store.commandsManager.closeEditor()">
Cancel
</button>
</div>
<style>
.commands-editor {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem 1.1rem 1.2rem;
}
.commands-editor-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.commands-editor-copy {
min-width: 0;
}
.commands-editor-title {
font-size: 1.02rem;
font-weight: 600;
}
.commands-editor-subtitle {
margin-top: 0.25rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
line-height: 1.45;
}
.commands-editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.commands-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.commands-field span {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.commands-field textarea,
.commands-field input {
width: 100%;
}
.commands-field select {
width: 100%;
}
.commands-field-checkbox {
justify-content: flex-end;
}
.commands-checkbox-inline {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--color-text-primary);
font-size: 0.86rem;
font-weight: 500;
}
.commands-checkbox-inline input {
width: auto;
}
.commands-field-body textarea {
min-height: 18rem;
resize: vertical;
font-family: "Roboto Mono", monospace;
}
.commands-editor-help {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.commands-help-chip {
display: inline-flex;
align-items: center;
padding: 0.22rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-highlight) 24%, transparent);
font-family: "Roboto Mono", monospace;
font-size: 0.8rem;
}
.commands-help-copy {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.commands-editor-path {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--color-text-secondary);
font-family: "Roboto Mono", monospace;
font-size: 0.78rem;
word-break: break-all;
}
@media (max-width: 760px) {
.commands-editor-grid {
grid-template-columns: 1fr;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,395 @@
<html>
<head>
<title>Commands</title>
<script type="module">
import { store } from "/plugins/_commands/webui/commands-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.commandsManager">
<div class="commands-manager" x-create="$store.commandsManager.onOpen()" x-destroy="$store.commandsManager.cleanup()">
<div class="commands-toolbar">
<div class="commands-toolbar-copy">
<div class="commands-title">Slash Commands</div>
<div class="commands-subtitle">
YAML-configured commands for the inline chat composer.
</div>
</div>
<div class="commands-toolbar-controls">
<label class="commands-select">
<span>Project</span>
<select x-model="$store.commandsManager.projectName" @change="$store.commandsManager.onScopeChanged()">
<option value="">Global</option>
<template x-for="project in $store.commandsManager.projects" :key="project.key">
<option :value="project.key" x-text="project.label"></option>
</template>
</select>
</label>
<div class="commands-toolbar-actions">
<button type="button" class="button secondary" @click="$store.commandsManager.refresh()">
<span class="material-symbols-outlined">refresh</span>
<span>Refresh</span>
</button>
<button type="button" class="button secondary" @click="$store.commandsManager.browseScopeFolder()">
<span class="material-symbols-outlined">folder_open</span>
<span>Browse Scope Folder</span>
</button>
<button type="button" class="button primary" @click="$store.commandsManager.openCreateCommand()">
<span class="material-symbols-outlined">add</span>
<span>Create Command</span>
</button>
</div>
</div>
</div>
<div class="commands-scope-banner" x-show="$store.commandsManager.scope">
<div class="commands-scope-banner-label">
<span class="material-symbols-outlined">target</span>
<span x-text="$store.commandsManager.selectedScopeLabel"></span>
</div>
<div class="commands-scope-banner-path" x-text="$store.commandsManager.selectedScopeDirectory"></div>
</div>
<div class="commands-loading" x-show="$store.commandsManager.loading">
<span class="material-symbols-outlined spinning">progress_activity</span>
<span>Loading commands...</span>
</div>
<div class="commands-list" x-show="!$store.commandsManager.loading">
<template x-if="$store.commandsManager.hasCommands">
<div class="commands-grid">
<template x-for="command in $store.commandsManager.commands" :key="command.path">
<article class="commands-card">
<div class="commands-card-header">
<div class="commands-card-copy">
<div class="commands-card-title">
<span class="commands-slash">/</span><span x-text="command.name"></span>
</div>
<div class="commands-card-description" x-text="command.description"></div>
</div>
<div class="commands-card-actions">
<button type="button" class="button icon" title="Edit command" @click="$store.commandsManager.openEditCommand(command)">
<span class="material-symbols-outlined">edit</span>
</button>
<button type="button" class="button icon" title="Duplicate command" @click="$store.commandsManager.duplicateCommand(command)">
<span class="material-symbols-outlined">content_copy</span>
</button>
<button type="button" class="button icon danger" title="Delete command" @click.stop="$confirmClick($event, () => $store.commandsManager.deleteCommand(command))">
<span class="material-symbols-outlined">delete</span>
</button>
</div>
</div>
<div class="commands-card-badges">
<span class="commands-badge scope" x-text="command.scope_label"></span>
<span class="commands-badge type" x-text="command.command_type === 'script' ? 'Python Hook' : 'Text Template'"></span>
<template x-if="command.override_count">
<span class="commands-badge override" x-text="$store.commandsManager.overrideBadgeLabel(command)"></span>
</template>
</div>
<template x-if="command.argument_hint">
<div class="commands-card-hint">
<span class="material-symbols-outlined">subdirectory_arrow_right</span>
<span x-text="command.argument_hint"></span>
</div>
</template>
<div class="commands-card-path" x-text="command.path"></div>
</article>
</template>
</div>
</template>
<template x-if="!$store.commandsManager.hasCommands">
<div class="commands-empty-state">
<div class="commands-empty-title">No commands in this scope yet.</div>
<div class="commands-empty-copy">
Create YAML-configured slash commands here and they will appear from <code>/</code> in chat.
</div>
<button type="button" class="button primary" @click="$store.commandsManager.openCreateCommand()">
<span class="material-symbols-outlined">add</span>
<span>Create Slash Command</span>
</button>
</div>
</template>
</div>
</div>
</template>
</div>
<style>
.commands-manager {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.commands-toolbar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 12px;
background: color-mix(in srgb, var(--color-panel) 88%, transparent);
}
.commands-toolbar-copy {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.commands-title {
font-size: 1.1rem;
font-weight: 600;
}
.commands-subtitle {
color: var(--color-text-secondary);
font-size: 0.92rem;
}
.commands-toolbar-controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
}
.commands-select {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 14rem;
flex: 1 1 14rem;
}
.commands-select span {
color: var(--color-text-secondary);
font-size: 0.82rem;
font-weight: 600;
}
.commands-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-left: auto;
}
.commands-scope-banner {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-radius: 12px;
background: color-mix(in srgb, var(--color-background) 84%, var(--color-panel));
}
.commands-scope-banner-label {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-weight: 600;
}
.commands-scope-banner-path {
color: var(--color-text-secondary);
font-family: "Roboto Mono", monospace;
font-size: 0.82rem;
word-break: break-all;
}
.commands-loading {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.9rem 1rem;
border-radius: 10px;
}
.commands-loading {
color: var(--color-text-secondary);
border: 1px dashed var(--color-border);
}
.commands-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 0.9rem;
}
.commands-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 12px;
background: color-mix(in srgb, var(--color-panel) 90%, transparent);
}
.commands-card-header {
display: flex;
gap: 0.75rem;
justify-content: space-between;
align-items: flex-start;
}
.commands-card-copy {
min-width: 0;
}
.commands-card-title {
font-size: 1rem;
font-weight: 600;
word-break: break-word;
}
.commands-slash {
color: var(--color-highlight);
}
.commands-card-description {
margin-top: 0.25rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.45;
}
.commands-card-actions {
display: inline-flex;
gap: 0.35rem;
flex-shrink: 0;
}
.commands-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.commands-badge {
display: inline-flex;
align-items: center;
padding: 0.22rem 0.5rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 600;
border: 1px solid transparent;
}
.commands-badge.scope {
background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
border-color: color-mix(in srgb, var(--color-highlight) 24%, transparent);
}
.commands-badge.override {
background: color-mix(in srgb, #f39c12 12%, transparent);
border-color: color-mix(in srgb, #f39c12 28%, transparent);
}
.commands-badge.type {
background: color-mix(in srgb, #5b8def 12%, transparent);
border-color: color-mix(in srgb, #5b8def 30%, transparent);
}
.commands-card-hint {
display: inline-flex;
align-items: flex-start;
gap: 0.35rem;
color: var(--color-text-secondary);
font-size: 0.86rem;
}
.commands-card-hint .material-symbols-outlined {
font-size: 1rem;
margin-top: 0.05rem;
}
.commands-card-path {
color: var(--color-text-secondary);
font-family: "Roboto Mono", monospace;
font-size: 0.78rem;
word-break: break-all;
}
.commands-empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
padding: 1.4rem;
border: 1px dashed var(--color-border);
border-radius: 12px;
background: color-mix(in srgb, var(--color-background) 84%, var(--color-panel));
}
.commands-empty-title {
font-size: 1rem;
font-weight: 600;
}
.commands-empty-copy {
color: var(--color-text-secondary);
max-width: 42rem;
}
.button.primary,
.button.secondary,
.button.icon {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.button.icon {
padding: 0.45rem;
min-width: 2.25rem;
justify-content: center;
}
.button.icon.danger {
color: var(--color-accent);
}
.spinning {
animation: commands-spin 1s linear infinite;
}
@keyframes commands-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 760px) {
.commands-toolbar-actions {
margin-left: 0;
}
.commands-select {
min-width: 0;
flex-basis: 100%;
}
.commands-toolbar-actions {
width: 100%;
}
.commands-toolbar-actions .button {
flex: 1 1 100%;
justify-content: center;
}
}
</style>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB