feat: config-gated /verbose command for messaging gateway (#3262)

* feat: config-gated /verbose command for messaging gateway

Add gateway_config_gate field to CommandDef, allowing cli_only commands
to be conditionally available in the gateway based on a config value.

- CommandDef gains gateway_config_gate: str | None — a config dotpath
  that, when truthy, overrides cli_only for gateway surfaces
- /verbose uses gateway_config_gate='display.tool_progress_command'
- Default is off (cli_only behavior preserved)
- When enabled, /verbose cycles tool_progress mode (off/new/all/verbose)
  in the gateway, saving to config.yaml — same cycle as the CLI
- Gateway helpers (help, telegram menus, slack mapping) dynamically
  check config to include/exclude config-gated commands
- GATEWAY_KNOWN_COMMANDS always includes config-gated commands so
  the gateway recognizes them and can respond appropriately
- Handles YAML 1.1 bool coercion (bare 'off' parses as False)
- 8 new tests for the config gate mechanism + gateway handler

* docs: document gateway_config_gate and /verbose messaging support

- AGENTS.md: add gateway_config_gate to CommandDef fields
- slash-commands.md: note /verbose can be enabled for messaging, update Notes
- configuration.md: add tool_progress_command to display section + usage note
- cli.md: cross-link to config docs for messaging enablement
- messaging/index.md: show tool_progress_command in config snippet
- plugins.md: add gateway_config_gate to register_command parameter table
This commit is contained in:
Teknium
2026-03-26 14:41:04 -07:00
committed by GitHub
parent 243ee67529
commit 72250b5f62
11 changed files with 375 additions and 18 deletions

View File

@@ -173,6 +173,7 @@ if canonical == "mycommand":
- `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`) - `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
- `cli_only` — only available in the interactive CLI - `cli_only` — only available in the interactive CLI
- `gateway_only` — only available in messaging platforms - `gateway_only` — only available in messaging platforms
- `gateway_config_gate` — config dotpath (e.g. `"display.tool_progress_command"`); when set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. `GATEWAY_KNOWN_COMMANDS` always includes config-gated commands so the gateway can dispatch them; help/menus only show them when the gate is open.
**Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically. **Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically.

View File

@@ -1716,6 +1716,9 @@ class GatewayRunner:
if canonical == "reasoning": if canonical == "reasoning":
return await self._handle_reasoning_command(event) return await self._handle_reasoning_command(event)
if canonical == "verbose":
return await self._handle_verbose_command(event)
if canonical == "provider": if canonical == "provider":
return await self._handle_provider_command(event) return await self._handle_provider_command(event)
@@ -3784,6 +3787,68 @@ class GatewayRunner:
else: else:
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)" return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
async def _handle_verbose_command(self, event: MessageEvent) -> str:
"""Handle /verbose command — cycle tool progress display mode.
Gated by ``display.tool_progress_command`` in config.yaml (default off).
When enabled, cycles the tool progress mode through off → new → all →
verbose → off, same as the CLI.
"""
import yaml
config_path = _hermes_home / "config.yaml"
# --- check config gate ------------------------------------------------
try:
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
gate_enabled = user_config.get("display", {}).get("tool_progress_command", False)
except Exception:
gate_enabled = False
if not gate_enabled:
return (
"The `/verbose` command is not enabled for messaging platforms.\n\n"
"Enable it in `config.yaml`:\n```yaml\n"
"display:\n tool_progress_command: true\n```"
)
# --- cycle mode -------------------------------------------------------
cycle = ["off", "new", "all", "verbose"]
descriptions = {
"off": "⚙️ Tool progress: **OFF** — no tool activity shown.",
"new": "⚙️ Tool progress: **NEW** — shown when tool changes.",
"all": "⚙️ Tool progress: **ALL** — every tool call shown.",
"verbose": "⚙️ Tool progress: **VERBOSE** — full args and results.",
}
raw_progress = user_config.get("display", {}).get("tool_progress", "all")
# YAML 1.1 parses bare "off" as boolean False — normalise back
if raw_progress is False:
current = "off"
elif raw_progress is True:
current = "all"
else:
current = str(raw_progress).lower()
if current not in cycle:
current = "all"
idx = (cycle.index(current) + 1) % len(cycle)
new_mode = cycle[idx]
# Save to config.yaml
try:
if "display" not in user_config or not isinstance(user_config.get("display"), dict):
user_config["display"] = {}
user_config["display"]["tool_progress"] = new_mode
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
return f"{descriptions[new_mode]}\n_(saved to config — takes effect on next message)_"
except Exception as e:
logger.warning("Failed to save tool_progress mode: %s", e)
return f"{descriptions[new_mode]}\n_(could not save to config: {e})_"
async def _handle_compress_command(self, event: MessageEvent) -> str: async def _handle_compress_command(self, event: MessageEvent) -> str:
"""Handle /compress command -- manually compress conversation context.""" """Handle /compress command -- manually compress conversation context."""
source = event.source source = event.source

View File

@@ -36,6 +36,7 @@ class CommandDef:
subcommands: tuple[str, ...] = () # tab-completable subcommands subcommands: tuple[str, ...] = () # tab-completable subcommands
cli_only: bool = False # only available in CLI cli_only: bool = False # only available in CLI
gateway_only: bool = False # only available in gateway/messaging gateway_only: bool = False # only available in gateway/messaging
gateway_config_gate: str | None = None # config dotpath; when truthy, overrides cli_only for gateway
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -87,7 +88,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration", CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
cli_only=True, aliases=("sb",)), cli_only=True, aliases=("sb",)),
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
"Configuration", cli_only=True), "Configuration", cli_only=True,
gateway_config_gate="display.tool_progress_command"),
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
args_hint="[level|show|hide]", args_hint="[level|show|hide]",
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")), subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
@@ -205,7 +207,7 @@ def rebuild_lookups() -> None:
GATEWAY_KNOWN_COMMANDS = frozenset( GATEWAY_KNOWN_COMMANDS = frozenset(
name name
for cmd in COMMAND_REGISTRY for cmd in COMMAND_REGISTRY
if not cmd.cli_only if not cmd.cli_only or cmd.gateway_config_gate
for name in (cmd.name, *cmd.aliases) for name in (cmd.name, *cmd.aliases)
) )
@@ -259,20 +261,76 @@ for _cmd in COMMAND_REGISTRY:
# Gateway helpers # Gateway helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Set of all command names + aliases recognized by the gateway # Set of all command names + aliases recognized by the gateway.
# Includes config-gated commands so the gateway can dispatch them
# (the handler checks the config gate at runtime).
GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset( GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
name name
for cmd in COMMAND_REGISTRY for cmd in COMMAND_REGISTRY
if not cmd.cli_only if not cmd.cli_only or cmd.gateway_config_gate
for name in (cmd.name, *cmd.aliases) for name in (cmd.name, *cmd.aliases)
) )
def _resolve_config_gates() -> set[str]:
"""Return canonical names of commands whose ``gateway_config_gate`` is truthy.
Reads ``config.yaml`` and walks the dot-separated key path for each
config-gated command. Returns an empty set on any error so callers
degrade gracefully.
"""
gated = [c for c in COMMAND_REGISTRY if c.gateway_config_gate]
if not gated:
return set()
try:
import yaml
config_path = os.path.join(
os.getenv("HERMES_HOME", os.path.expanduser("~/.hermes")),
"config.yaml",
)
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
else:
cfg = {}
except Exception:
return set()
result: set[str] = set()
for cmd in gated:
val: Any = cfg
for key in cmd.gateway_config_gate.split("."):
if isinstance(val, dict):
val = val.get(key)
else:
val = None
break
if val:
result.add(cmd.name)
return result
def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool:
"""Check if *cmd* should appear in gateway surfaces (help, menus, mappings).
Unconditionally available when ``cli_only`` is False. When ``cli_only``
is True but ``gateway_config_gate`` is set, the command is available only
when the config value is truthy. Pass *config_overrides* (from
``_resolve_config_gates()``) to avoid re-reading config for every command.
"""
if not cmd.cli_only:
return True
if cmd.gateway_config_gate:
overrides = config_overrides if config_overrides is not None else _resolve_config_gates()
return cmd.name in overrides
return False
def gateway_help_lines() -> list[str]: def gateway_help_lines() -> list[str]:
"""Generate gateway help text lines from the registry.""" """Generate gateway help text lines from the registry."""
overrides = _resolve_config_gates()
lines: list[str] = [] lines: list[str] = []
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if cmd.cli_only: if not _is_gateway_available(cmd, overrides):
continue continue
args = f" {cmd.args_hint}" if cmd.args_hint else "" args = f" {cmd.args_hint}" if cmd.args_hint else ""
alias_parts: list[str] = [] alias_parts: list[str] = []
@@ -293,9 +351,10 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
underscores. Aliases are skipped -- Telegram shows one menu entry per underscores. Aliases are skipped -- Telegram shows one menu entry per
canonical command. canonical command.
""" """
overrides = _resolve_config_gates()
result: list[tuple[str, str]] = [] result: list[tuple[str, str]] = []
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if cmd.cli_only: if not _is_gateway_available(cmd, overrides):
continue continue
tg_name = cmd.name.replace("-", "_") tg_name = cmd.name.replace("-", "_")
result.append((tg_name, cmd.description)) result.append((tg_name, cmd.description))
@@ -308,9 +367,10 @@ def slack_subcommand_map() -> dict[str, str]:
Maps both canonical names and aliases so /hermes bg do stuff works Maps both canonical names and aliases so /hermes bg do stuff works
the same as /hermes background do stuff. the same as /hermes background do stuff.
""" """
overrides = _resolve_config_gates()
mapping: dict[str, str] = {} mapping: dict[str, str] = {}
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if cmd.cli_only: if not _is_gateway_available(cmd, overrides):
continue continue
mapping[cmd.name] = f"/{cmd.name}" mapping[cmd.name] = f"/{cmd.name}"
for alias in cmd.aliases: for alias in cmd.aliases:

View File

@@ -269,6 +269,7 @@ DEFAULT_CONFIG = {
"streaming": False, "streaming": False,
"show_cost": False, # Show $ cost in the status bar (off by default) "show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "default", "skin": "default",
"tool_progress_command": False, # Enable /verbose command in messaging gateway
}, },
# Privacy settings # Privacy settings

View File

@@ -0,0 +1,146 @@
"""Tests for gateway /verbose command (config-gated tool progress cycling)."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
import yaml
import gateway.run as gateway_run
from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
def _make_event(text="/verbose", platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"):
"""Build a MessageEvent for testing."""
source = SessionSource(
platform=platform,
user_id=user_id,
chat_id=chat_id,
user_name="testuser",
)
return MessageEvent(text=text, source=source)
def _make_runner():
"""Create a bare GatewayRunner without calling __init__."""
runner = object.__new__(gateway_run.GatewayRunner)
runner.adapters = {}
runner._ephemeral_system_prompt = ""
runner._prefill_messages = []
runner._reasoning_config = None
runner._show_reasoning = False
runner._provider_routing = {}
runner._fallback_model = None
runner._running_agents = {}
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.hooks.loaded_hooks = []
runner._session_db = None
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
return runner
class TestVerboseCommand:
"""Tests for _handle_verbose_command in the gateway."""
@pytest.mark.asyncio
async def test_disabled_by_default(self, tmp_path, monkeypatch):
"""When tool_progress_command is false, /verbose returns an info message."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text("display:\n tool_progress: all\n", encoding="utf-8")
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
runner = _make_runner()
result = await runner._handle_verbose_command(_make_event())
assert "not enabled" in result.lower()
assert "tool_progress_command" in result
@pytest.mark.asyncio
async def test_enabled_cycles_mode(self, tmp_path, monkeypatch):
"""When enabled, /verbose cycles tool_progress mode."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"display:\n tool_progress_command: true\n tool_progress: all\n",
encoding="utf-8",
)
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
runner = _make_runner()
result = await runner._handle_verbose_command(_make_event())
# all -> verbose
assert "VERBOSE" in result
# Verify config was saved
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert saved["display"]["tool_progress"] == "verbose"
@pytest.mark.asyncio
async def test_cycles_through_all_modes(self, tmp_path, monkeypatch):
"""Calling /verbose repeatedly cycles through all four modes."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"display:\n tool_progress_command: true\n tool_progress: 'off'\n",
encoding="utf-8",
)
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
runner = _make_runner()
# off -> new -> all -> verbose -> off
expected = ["new", "all", "verbose", "off"]
for mode in expected:
result = await runner._handle_verbose_command(_make_event())
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert saved["display"]["tool_progress"] == mode, \
f"Expected {mode}, got {saved['display']['tool_progress']}"
@pytest.mark.asyncio
async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch):
"""When tool_progress is not in config, defaults to 'all' then cycles to verbose."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"display:\n tool_progress_command: true\n",
encoding="utf-8",
)
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
runner = _make_runner()
result = await runner._handle_verbose_command(_make_event())
# default "all" -> verbose
assert "VERBOSE" in result
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert saved["display"]["tool_progress"] == "verbose"
@pytest.mark.asyncio
async def test_no_config_file_returns_disabled(self, tmp_path, monkeypatch):
"""When config.yaml doesn't exist, command reports disabled."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
# No config.yaml
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
runner = _make_runner()
result = await runner._handle_verbose_command(_make_event())
assert "not enabled" in result.lower()
def test_verbose_is_in_gateway_known_commands(self):
"""The /verbose command is recognized by the gateway dispatch."""
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS
assert "verbose" in GATEWAY_KNOWN_COMMANDS

View File

@@ -134,12 +134,19 @@ class TestDerivedDicts:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestGatewayKnownCommands: class TestGatewayKnownCommands:
def test_excludes_cli_only(self): def test_excludes_cli_only_without_config_gate(self):
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if cmd.cli_only: if cmd.cli_only and not cmd.gateway_config_gate:
assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \ assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \
f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS" f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS"
def test_includes_config_gated_cli_only(self):
"""Commands with gateway_config_gate are always in GATEWAY_KNOWN_COMMANDS."""
for cmd in COMMAND_REGISTRY:
if cmd.gateway_config_gate:
assert cmd.name in GATEWAY_KNOWN_COMMANDS, \
f"config-gated command '{cmd.name}' should be in GATEWAY_KNOWN_COMMANDS"
def test_includes_gateway_commands(self): def test_includes_gateway_commands(self):
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if not cmd.cli_only: if not cmd.cli_only:
@@ -160,11 +167,11 @@ class TestGatewayHelpLines:
lines = gateway_help_lines() lines = gateway_help_lines()
assert len(lines) > 10 assert len(lines) > 10
def test_excludes_cli_only_commands(self): def test_excludes_cli_only_commands_without_config_gate(self):
lines = gateway_help_lines() lines = gateway_help_lines()
joined = "\n".join(lines) joined = "\n".join(lines)
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if cmd.cli_only: if cmd.cli_only and not cmd.gateway_config_gate:
assert f"`/{cmd.name}" not in joined, \ assert f"`/{cmd.name}" not in joined, \
f"cli_only command /{cmd.name} should not be in gateway help" f"cli_only command /{cmd.name} should not be in gateway help"
@@ -188,10 +195,10 @@ class TestTelegramBotCommands:
for name, _ in telegram_bot_commands(): for name, _ in telegram_bot_commands():
assert "-" not in name, f"Telegram command '{name}' contains a hyphen" assert "-" not in name, f"Telegram command '{name}' contains a hyphen"
def test_excludes_cli_only(self): def test_excludes_cli_only_without_config_gate(self):
names = {name for name, _ in telegram_bot_commands()} names = {name for name, _ in telegram_bot_commands()}
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if cmd.cli_only: if cmd.cli_only and not cmd.gateway_config_gate:
tg_name = cmd.name.replace("-", "_") tg_name = cmd.name.replace("-", "_")
assert tg_name not in names assert tg_name not in names
@@ -211,13 +218,84 @@ class TestSlackSubcommandMap:
assert "bg" in mapping assert "bg" in mapping
assert "reset" in mapping assert "reset" in mapping
def test_excludes_cli_only(self): def test_excludes_cli_only_without_config_gate(self):
mapping = slack_subcommand_map() mapping = slack_subcommand_map()
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if cmd.cli_only: if cmd.cli_only and not cmd.gateway_config_gate:
assert cmd.name not in mapping assert cmd.name not in mapping
# ---------------------------------------------------------------------------
# Config-gated gateway commands
# ---------------------------------------------------------------------------
class TestGatewayConfigGate:
"""Tests for the gateway_config_gate mechanism on CommandDef."""
def test_verbose_has_config_gate(self):
cmd = resolve_command("verbose")
assert cmd is not None
assert cmd.cli_only is True
assert cmd.gateway_config_gate == "display.tool_progress_command"
def test_verbose_in_gateway_known_commands(self):
"""Config-gated commands are always recognized by the gateway."""
assert "verbose" in GATEWAY_KNOWN_COMMANDS
def test_config_gate_excluded_from_help_when_off(self, tmp_path, monkeypatch):
"""When the config gate is falsy, the command should not appear in help."""
# Write a config with the gate off (default)
config_file = tmp_path / "config.yaml"
config_file.write_text("display:\n tool_progress_command: false\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
lines = gateway_help_lines()
joined = "\n".join(lines)
assert "`/verbose" not in joined
def test_config_gate_included_in_help_when_on(self, tmp_path, monkeypatch):
"""When the config gate is truthy, the command should appear in help."""
config_file = tmp_path / "config.yaml"
config_file.write_text("display:\n tool_progress_command: true\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
lines = gateway_help_lines()
joined = "\n".join(lines)
assert "`/verbose" in joined
def test_config_gate_excluded_from_telegram_when_off(self, tmp_path, monkeypatch):
config_file = tmp_path / "config.yaml"
config_file.write_text("display:\n tool_progress_command: false\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
names = {name for name, _ in telegram_bot_commands()}
assert "verbose" not in names
def test_config_gate_included_in_telegram_when_on(self, tmp_path, monkeypatch):
config_file = tmp_path / "config.yaml"
config_file.write_text("display:\n tool_progress_command: true\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
names = {name for name, _ in telegram_bot_commands()}
assert "verbose" in names
def test_config_gate_excluded_from_slack_when_off(self, tmp_path, monkeypatch):
config_file = tmp_path / "config.yaml"
config_file.write_text("display:\n tool_progress_command: false\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
mapping = slack_subcommand_map()
assert "verbose" not in mapping
def test_config_gate_included_in_slack_when_on(self, tmp_path, monkeypatch):
config_file = tmp_path / "config.yaml"
config_file.write_text("display:\n tool_progress_command: true\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
mapping = slack_subcommand_map()
assert "verbose" in mapping
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Autocomplete (SlashCommandCompleter) # Autocomplete (SlashCommandCompleter)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -46,7 +46,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/provider` | Show available providers and current provider | | `/provider` | Show available providers and current provider |
| `/prompt` | View/set custom system prompt | | `/prompt` | View/set custom system prompt |
| `/personality` | Set a predefined personality | | `/personality` | Set a predefined personality |
| `/verbose` | Cycle tool progress display: off → new → all → verbose | | `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. |
| `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) | | `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) |
| `/skin` | Show or change the display skin/theme | | `/skin` | Show or change the display skin/theme |
| `/voice [on\|off\|tts\|status]` | Toggle CLI voice mode and spoken playback. Recording uses `voice.record_key` (default: `Ctrl+B`). | | `/voice [on\|off\|tts\|status]` | Toggle CLI voice mode and spoken playback. Recording uses `voice.record_key` (default: `Ctrl+B`). |
@@ -125,7 +125,8 @@ The messaging gateway supports the following built-in commands inside Telegram,
## Notes ## Notes
- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/prompt`, `/cron`, `/skills`, `/platforms`, `/paste`, `/verbose`, `/statusbar`, and `/plugins` are **CLI-only** commands. - `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/prompt`, `/cron`, `/skills`, `/platforms`, `/paste`, `/statusbar`, and `/plugins` are **CLI-only** commands.
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
- `/status`, `/sethome`, `/update`, `/approve`, and `/deny` are **messaging-only** commands. - `/status`, `/sethome`, `/update`, `/approve`, and `/deny` are **messaging-only** commands.
- `/background`, `/voice`, `/reload-mcp`, and `/rollback` work in **both** the CLI and the messaging gateway. - `/background`, `/voice`, `/reload-mcp`, and `/rollback` work in **both** the CLI and the messaging gateway.
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.

View File

@@ -230,7 +230,7 @@ The CLI shows animated feedback as the agent works:
┊ 📄 web_extract (2.1s) ┊ 📄 web_extract (2.1s)
``` ```
Cycle through display modes with `/verbose`: `off → new → all → verbose`. Cycle through display modes with `/verbose`: `off → new → all → verbose`. This command can also be enabled for messaging platforms — see [configuration](/docs/user-guide/configuration#display-settings).
## Session Management ## Session Management

View File

@@ -1163,6 +1163,7 @@ This controls both the `text_to_speech` tool and spoken replies in voice mode (`
```yaml ```yaml
display: display:
tool_progress: all # off | new | all | verbose tool_progress: all # off | new | all | verbose
tool_progress_command: false # Enable /verbose slash command in messaging gateway
skin: default # Built-in or custom CLI skin (see user-guide/features/skins) skin: default # Built-in or custom CLI skin (see user-guide/features/skins)
theme_mode: auto # auto | light | dark — color scheme for skin-aware rendering theme_mode: auto # auto | light | dark — color scheme for skin-aware rendering
personality: "kawaii" # Legacy cosmetic field still surfaced in some summaries personality: "kawaii" # Legacy cosmetic field still surfaced in some summaries
@@ -1194,6 +1195,8 @@ This works with any skin — built-in or custom. Skin authors can provide `color
| `all` | Every tool call with a short preview (default) | | `all` | Every tool call with a short preview (default) |
| `verbose` | Full args, results, and debug logs | | `verbose` | Full args, results, and debug logs |
In the CLI, cycle through these modes with `/verbose`. To use `/verbose` in messaging platforms (Telegram, Discord, Slack, etc.), set `tool_progress_command: true` in the `display` section above. The command will then cycle the mode and save to config.
## Privacy ## Privacy
```yaml ```yaml

View File

@@ -83,6 +83,7 @@ The handler receives the argument string (everything after `/greet`) and returns
| `aliases` | Tuple of alternative names | | `aliases` | Tuple of alternative names |
| `cli_only` | Only available in CLI | | `cli_only` | Only available in CLI |
| `gateway_only` | Only available in messaging platforms | | `gateway_only` | Only available in messaging platforms |
| `gateway_config_gate` | Config dotpath (e.g. `"display.my_option"`). When set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. |
## Managing plugins ## Managing plugins

View File

@@ -188,6 +188,7 @@ Control how much tool activity is displayed in `~/.hermes/config.yaml`:
```yaml ```yaml
display: display:
tool_progress: all # off | new | all | verbose tool_progress: all # off | new | all | verbose
tool_progress_command: false # set to true to enable /verbose in messaging
``` ```
When enabled, the bot sends status messages as it works: When enabled, the bot sends status messages as it works: