mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-10 01:18:29 +00:00
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:
parent
3d87998821
commit
9c9a4e00ca
46 changed files with 4717 additions and 0 deletions
|
|
@ -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. |
|
||||
|
|
|
|||
44
plugins/_commands/AGENTS.md
Normal file
44
plugins/_commands/AGENTS.md
Normal 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
21
plugins/_commands/LICENSE
Normal 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
146
plugins/_commands/README.md
Normal 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.
|
||||
1
plugins/_commands/__init__.py
Normal file
1
plugins/_commands/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Commands plugin package."""
|
||||
165
plugins/_commands/api/commands.py
Normal file
165
plugins/_commands/api/commands.py
Normal 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}
|
||||
5
plugins/_commands/commands/attach.command.yaml
Normal file
5
plugins/_commands/commands/attach.command.yaml
Normal 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
|
||||
5
plugins/_commands/commands/browser.command.yaml
Normal file
5
plugins/_commands/commands/browser.command.yaml
Normal 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
|
||||
5
plugins/_commands/commands/chat.command.yaml
Normal file
5
plugins/_commands/commands/chat.command.yaml
Normal 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
|
||||
6
plugins/_commands/commands/chats.command.yaml
Normal file
6
plugins/_commands/commands/chats.command.yaml
Normal 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
|
||||
4
plugins/_commands/commands/clear.command.yaml
Normal file
4
plugins/_commands/commands/clear.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: clear
|
||||
description: Clear the visible chat log.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/compact.command.yaml
Normal file
4
plugins/_commands/commands/compact.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: compact
|
||||
description: Open the connector-backed compaction confirmation flow.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
5
plugins/_commands/commands/computer-use.command.yaml
Normal file
5
plugins/_commands/commands/computer-use.command.yaml
Normal 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
|
||||
305
plugins/_commands/commands/connector_commands.py
Normal file
305
plugins/_commands/commands/connector_commands.py
Normal 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("|", "\\|")
|
||||
4
plugins/_commands/commands/copy.command.yaml
Normal file
4
plugins/_commands/commands/copy.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: copy
|
||||
description: Copy the currently visible transcript text to the clipboard.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/models.command.yaml
Normal file
4
plugins/_commands/commands/models.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: models
|
||||
description: Open Main/Utility model runtime editor.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/new.command.yaml
Normal file
4
plugins/_commands/commands/new.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: new
|
||||
description: Create a brand-new empty chat context.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/nudge.command.yaml
Normal file
4
plugins/_commands/commands/nudge.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: nudge
|
||||
description: Nudge the current agent run.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/pause.command.yaml
Normal file
4
plugins/_commands/commands/pause.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: pause
|
||||
description: Pause the active agent run.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/plugins.command.yaml
Normal file
4
plugins/_commands/commands/plugins.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: plugins
|
||||
description: Open the installed-only Agent Zero plugin toggle view.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/presets.command.yaml
Normal file
4
plugins/_commands/commands/presets.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: presets
|
||||
description: Open preset picker with Main/Utility model details.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
5
plugins/_commands/commands/profile.command.yaml
Normal file
5
plugins/_commands/commands/profile.command.yaml
Normal 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
|
||||
5
plugins/_commands/commands/project.command.yaml
Normal file
5
plugins/_commands/commands/project.command.yaml
Normal 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
|
||||
5
plugins/_commands/commands/queue.command.yaml
Normal file
5
plugins/_commands/commands/queue.command.yaml
Normal 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
|
||||
4
plugins/_commands/commands/quit.command.yaml
Normal file
4
plugins/_commands/commands/quit.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: quit
|
||||
description: Disconnect and exit the CLI.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/resume.command.yaml
Normal file
4
plugins/_commands/commands/resume.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: resume
|
||||
description: Resume a paused agent run.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/send.command.yaml
Normal file
4
plugins/_commands/commands/send.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name: send
|
||||
description: Send all queued messages now.
|
||||
type: script
|
||||
script_path: connector_commands.py
|
||||
4
plugins/_commands/commands/status.command.yaml
Normal file
4
plugins/_commands/commands/status.command.yaml
Normal 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
|
||||
|
|
@ -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)*")
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
1
plugins/_commands/helpers/__init__.py
Normal file
1
plugins/_commands/helpers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Helpers for the commands plugin."""
|
||||
1150
plugins/_commands/helpers/commands.py
Normal file
1150
plugins/_commands/helpers/commands.py
Normal file
File diff suppressed because it is too large
Load diff
8
plugins/_commands/plugin.yaml
Normal file
8
plugins/_commands/plugin.yaml
Normal 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
|
||||
|
|
@ -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`
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Describe the work to perform here.
|
||||
|
||||
{raw}
|
||||
|
|
@ -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
|
||||
11
plugins/_commands/tests/conftest.py
Normal file
11
plugins/_commands/tests/conftest.py
Normal 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)
|
||||
367
plugins/_commands/tests/test_commands_plugin.py
Normal file
367
plugins/_commands/tests/test_commands_plugin.py
Normal 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"
|
||||
)
|
||||
97
plugins/_commands/tests/test_legacy_migration.py
Normal file
97
plugins/_commands/tests/test_legacy_migration.py
Normal 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()
|
||||
337
plugins/_commands/tests/test_plugin_command_discovery.py
Normal file
337
plugins/_commands/tests/test_plugin_command_discovery.py
Normal 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)
|
||||
513
plugins/_commands/webui/commands-slash-store.js
Normal file
513
plugins/_commands/webui/commands-slash-store.js
Normal 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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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);
|
||||
417
plugins/_commands/webui/commands-store.js
Normal file
417
plugins/_commands/webui/commands-store.js
Normal 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);
|
||||
240
plugins/_commands/webui/editor.html
Normal file
240
plugins/_commands/webui/editor.html
Normal 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>
|
||||
395
plugins/_commands/webui/main.html
Normal file
395
plugins/_commands/webui/main.html
Normal 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>
|
||||
BIN
plugins/_commands/webui/thumbnail.png
Normal file
BIN
plugins/_commands/webui/thumbnail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Loading…
Add table
Add a link
Reference in a new issue