diff --git a/cli.py b/cli.py index 1a92ae937786..fa5d56e8760c 100644 --- a/cli.py +++ b/cli.py @@ -8372,6 +8372,8 @@ def process_command(self, command: str) -> bool: print(f"Plugin system error: {e}") elif canonical == "rollback": self._handle_rollback_command(cmd_original) + elif canonical == "diff": + self._handle_diff_command(cmd_original) elif canonical == "snapshot": self._handle_snapshot_command(cmd_original) elif canonical == "stop": diff --git a/gateway/run.py b/gateway/run.py index ea0ac5c51537..2ce9172c96fd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8734,6 +8734,9 @@ async def _do_undo(): if canonical == "rollback": return await self._handle_rollback_command(event) + if canonical == "diff": + return await self._handle_diff_command(event) + if canonical == "background": return await self._handle_background_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index b2b8089b51b7..98b07bb1280c 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2263,6 +2263,64 @@ async def _handle_rollback_command(self, event: MessageEvent) -> str: ) return t("gateway.rollback.restore_failed", error=result["error"]) + async def _handle_diff_command(self, event: MessageEvent) -> str: + """Handle /diff - show everything Hermes has changed in this directory. + + Cumulative diff from the earliest retained checkpoint (the pre-edit + baseline) to the current working tree. ``/diff --stat`` shows just the + summary. Complements ``/rollback diff `` (single-checkpoint preview). + """ + from gateway.run import _hermes_home + from tools.checkpoint_manager import CheckpointManager + + # Read checkpoint config from config.yaml (mirrors _handle_rollback_command). + cp_cfg = {} + try: + import yaml as _y + _cfg_path = _hermes_home / "config.yaml" + if _cfg_path.exists(): + with open(_cfg_path, encoding="utf-8") as _f: + _data = _y.safe_load(_f) or {} + cp_cfg = _data.get("checkpoints", {}) + if isinstance(cp_cfg, bool): + cp_cfg = {"enabled": cp_cfg} + except Exception: + pass + + if not cp_cfg.get("enabled", False): + return t("gateway.diff.not_enabled") + + mgr = CheckpointManager( + enabled=True, + max_snapshots=cp_cfg.get("max_snapshots", 50), + max_total_size_mb=cp_cfg.get("max_total_size_mb", 500), + max_file_size_mb=cp_cfg.get("max_file_size_mb", 10), + ) + + cwd = os.getenv("TERMINAL_CWD", str(Path.home())) + stat_only = event.get_command_args().strip().lower() in {"--stat", "stat"} + + result = mgr.session_diff(cwd) + if not result.get("success"): + return t("gateway.diff.failed", error=result.get("error", "Could not generate diff")) + + stat = result.get("stat", "") + diff = result.get("diff", "") + if result.get("empty") or (not stat and not diff): + return t("gateway.diff.no_changes") + + out: list[str] = [] + if stat: + out.append(stat) + if not stat_only and diff: + diff_lines = diff.splitlines() + if len(diff_lines) > 60: + diff = "\n".join(diff_lines[:60]) + ( + f"\n... ({len(diff_lines) - 60} more lines - use /diff --stat for a summary)" + ) + out.append(f"```diff\n{diff}\n```") + return "\n\n".join(out) + async def _handle_background_command(self, event: MessageEvent) -> str: """Handle /background — run a prompt in a separate background session. diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index eefce82461a0..7b331a20b822 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -137,6 +137,58 @@ def _handle_rollback_command(self, command: str): else: print(f" ❌ {result['error']}") + def _handle_diff_command(self, command: str): + """Handle /diff - show everything Hermes has changed in this directory. + + Unlike ``/rollback diff `` (which previews changes since one chosen + checkpoint), ``/diff`` shows the cumulative diff from the earliest + retained checkpoint - the pre-edit baseline - to the current working + tree, answering "what has Hermes changed here?" in one view. + + Syntax: + /diff - full cumulative diff + /diff --stat - summary (changed files + insertions/deletions) + """ + if not hasattr(self, 'agent') or not self.agent: + print(" No active agent session.") + return + + mgr = self.agent._checkpoint_mgr + if not mgr.enabled: + print(" Checkpoints are not enabled.") + print(" Enable with: hermes --checkpoints") + print(" Or in config.yaml: checkpoints: { enabled: true }") + return + + cwd = os.getenv("TERMINAL_CWD", os.getcwd()) + parts = command.split() + stat_only = any(a.lower() in {"--stat", "stat"} for a in parts[1:]) + + result = mgr.session_diff(cwd) + if not result.get("success"): + print(f" {result.get('error', 'Could not generate diff')}") + return + + stat = result.get("stat", "") + diff = result.get("diff", "") + if result.get("empty") or (not stat and not diff): + print(" No changes - Hermes hasn't edited any files here yet.") + return + + if stat: + print(f"\n{stat}") + if stat_only: + return + if diff: + # Limit diff output to avoid flooding the terminal (mirrors + # /rollback diff). Full diff is always available via git. + diff_lines = diff.splitlines() + if len(diff_lines) > 80: + print("\n".join(diff_lines[:80])) + print(f"\n ... ({len(diff_lines) - 80} more lines - run /diff --stat for a summary)") + else: + print(f"\n{diff}") + def _handle_snapshot_command(self, command: str): """Handle /snapshot — lightweight state snapshots for Hermes config/state. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 39cf526d2cc1..ca8304fa99c3 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -94,6 +94,8 @@ class CommandDef: args_hint="[number]"), CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session", cli_only=True, aliases=("snap",), args_hint="[create|restore |prune]"), + CommandDef("diff", "Show everything Hermes has changed here (cumulative git diff)", "Session", + args_hint="[--stat]"), CommandDef("stop", "Kill all running background processes", "Session"), CommandDef("approve", "Approve a pending dangerous command", "Session", gateway_only=True, args_hint="[session|always]"), diff --git a/locales/en.yaml b/locales/en.yaml index a8a132622f44..3d872d6c5815 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -265,6 +265,11 @@ gateway: restored: "✅ Restored to checkpoint {hash}: {reason}\nA pre-rollback snapshot was saved automatically." restore_failed: "❌ {error}" + diff: + not_enabled: "Checkpoints are not enabled, so there's nothing to diff.\nEnable in config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + no_changes: "No changes - Hermes hasn't edited any files here yet." + failed: "{error}" + set_home: save_failed: "Failed to save home channel: {error}" success: "✅ Home channel set to **{name}** (ID: {chat_id}).\nCron jobs and cross-platform messages will be delivered here." diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 6e30bd46e69b..9cbf4e0ad566 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -263,6 +263,7 @@ The registry of record is `hermes_cli/commands.py` — every consumer /compress Manually compress context /stop Kill background processes /rollback [N] Restore filesystem checkpoint +/diff [--stat] Show cumulative diff of everything changed this session /snapshot [sub] Create or restore state snapshots of Hermes config/state (CLI) /background Run prompt in background /queue Queue for next turn diff --git a/tests/gateway/test_diff_command.py b/tests/gateway/test_diff_command.py new file mode 100644 index 000000000000..143830f3a9b9 --- /dev/null +++ b/tests/gateway/test_diff_command.py @@ -0,0 +1,107 @@ +"""End-to-end tests for the gateway ``/diff`` command. + +Exercises the real handler against a real checkpoint store and git, proving +the messaging surface returns the cumulative working-tree diff (and degrades +to friendly messages when checkpoints are off or nothing has changed). +""" + +import shutil + +import pytest + +import gateway.run as gateway_run +import tools.checkpoint_manager as cpm +from gateway.config import Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + +pytestmark = pytest.mark.skipif( + shutil.which("git") is None, reason="git required for checkpoint diffs" +) + + +def _runner(): + runner = object.__new__(gateway_run.GatewayRunner) + runner.session_store = None + runner.config = None + return runner + + +def _event(text: str) -> MessageEvent: + source = SessionSource( + platform=Platform.TELEGRAM, + user_id="user-1", + chat_id="chat-1", + user_name="tester", + chat_type="dm", + ) + return MessageEvent(text=text, source=source) + + +def _enable_checkpoints(tmp_path, monkeypatch, enabled=True): + home = tmp_path / "home" + home.mkdir() + (home / "config.yaml").write_text( + f"checkpoints:\n enabled: {str(enabled).lower()}\n", encoding="utf-8" + ) + monkeypatch.setattr(gateway_run, "_hermes_home", home, raising=False) + monkeypatch.setattr(cpm, "CHECKPOINT_BASE", tmp_path / "checkpoints") + + +@pytest.mark.asyncio +async def test_diff_reports_cumulative_changes(tmp_path, monkeypatch): + _enable_checkpoints(tmp_path, monkeypatch) + project = tmp_path / "project" + project.mkdir() + (project / "main.py").write_text("print('hello')\n", encoding="utf-8") + monkeypatch.setenv("TERMINAL_CWD", str(project)) + + # Baseline checkpoint (pre-edit) then an edit, so a diff exists. + mgr = cpm.CheckpointManager(enabled=True, max_snapshots=50) + assert mgr.ensure_checkpoint(str(project), "baseline") is True + (project / "main.py").write_text("print('changed')\n", encoding="utf-8") + + result = await _runner()._handle_diff_command(_event("/diff")) + + assert "-print('hello')" in result + assert "+print('changed')" in result + + +@pytest.mark.asyncio +async def test_diff_stat_only_omits_body(tmp_path, monkeypatch): + _enable_checkpoints(tmp_path, monkeypatch) + project = tmp_path / "project" + project.mkdir() + (project / "main.py").write_text("a = 1\n", encoding="utf-8") + monkeypatch.setenv("TERMINAL_CWD", str(project)) + + mgr = cpm.CheckpointManager(enabled=True, max_snapshots=50) + mgr.ensure_checkpoint(str(project), "baseline") + (project / "main.py").write_text("a = 2\n", encoding="utf-8") + + result = await _runner()._handle_diff_command(_event("/diff --stat")) + + assert "main.py" in result + assert "+a = 2" not in result # body suppressed + + +@pytest.mark.asyncio +async def test_diff_no_changes_message(tmp_path, monkeypatch): + _enable_checkpoints(tmp_path, monkeypatch) + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(project)) + + result = await _runner()._handle_diff_command(_event("/diff")) + + assert "No changes" in result + + +@pytest.mark.asyncio +async def test_diff_disabled_message(tmp_path, monkeypatch): + _enable_checkpoints(tmp_path, monkeypatch, enabled=False) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + + result = await _runner()._handle_diff_command(_event("/diff")) + + assert "not enabled" in result.lower() diff --git a/tests/hermes_cli/test_diff_command.py b/tests/hermes_cli/test_diff_command.py new file mode 100644 index 000000000000..62d2529dc600 --- /dev/null +++ b/tests/hermes_cli/test_diff_command.py @@ -0,0 +1,88 @@ +"""Tests for the CLI ``/diff`` command handler. + +``/diff`` shows the cumulative diff of everything Hermes changed in the +working directory (earliest retained checkpoint to working tree), the +session-wide counterpart to ``/rollback diff ``. These assert the handler +renders the manager's ``session_diff`` result, honours ``--stat``, and +degrades gracefully when checkpoints are off / empty / no agent. +""" + +import contextlib +import io + +from hermes_cli.cli_commands_mixin import CLICommandsMixin + + +class _Mgr: + def __init__(self, result, enabled=True): + self.enabled = enabled + self._result = result + self.calls = [] + + def session_diff(self, cwd): + self.calls.append(cwd) + return self._result + + +class _Agent: + def __init__(self, mgr): + self._checkpoint_mgr = mgr + + +class _Stub(CLICommandsMixin): + def __init__(self, agent=None): + self.agent = agent + + +def _run(stub, command): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + stub._handle_diff_command(command) + return buf.getvalue() + + +def test_diff_prints_stat_and_diff(): + mgr = _Mgr({ + "success": True, + "stat": " main.py | 2 +-", + "diff": "--- a/main.py\n+++ b/main.py\n-print('hello')\n+print('v3')\n", + }) + out = _run(_Stub(_Agent(mgr)), "/diff") + assert " main.py | 2 +-" in out + assert "+print('v3')" in out + assert mgr.calls # session_diff was consulted + + +def test_diff_stat_only_suppresses_body(): + mgr = _Mgr({ + "success": True, + "stat": " main.py | 2 +-", + "diff": "+print('v3')\n", + }) + out = _run(_Stub(_Agent(mgr)), "/diff --stat") + assert " main.py | 2 +-" in out + assert "+print('v3')" not in out + + +def test_diff_empty_reports_no_changes(): + mgr = _Mgr({"success": True, "stat": "", "diff": "", "empty": True}) + out = _run(_Stub(_Agent(mgr)), "/diff") + assert "No changes" in out + + +def test_diff_disabled_explains_how_to_enable(): + mgr = _Mgr({"success": True, "stat": "", "diff": ""}, enabled=False) + out = _run(_Stub(_Agent(mgr)), "/diff") + assert "not enabled" in out.lower() + assert not mgr.calls # short-circuits before touching the store + + +def test_diff_without_agent_is_graceful(): + out = _run(_Stub(agent=None), "/diff") + assert "No active agent session" in out + + +def test_diff_failure_surfaces_error(): + mgr = _Mgr({"success": False, "error": "boom"}) + out = _run(_Stub(_Agent(mgr)), "/diff") + assert "boom" in out diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 97f584d07320..6111dbc593ce 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4433,6 +4433,59 @@ def restore(self, cwd, target, file_path=None): assert calls["args"][2] == "src/app.tsx" +def test_diff_session_returns_cumulative_diff(): + """diff.session surfaces the manager's cumulative session_diff result.""" + + class _Mgr: + enabled = True + + def session_diff(self, cwd): + return { + "success": True, + "stat": " main.py | 2 +-", + "diff": "--- a/main.py\n+++ b/main.py\n@@\n-print('hello')\n+print('v3')\n", + } + + server._sessions["sid"] = _session( + agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[] + ) + try: + resp = server.handle_request( + {"id": "1", "method": "diff.session", "params": {"session_id": "sid"}} + ) + finally: + server._sessions.pop("sid", None) + + assert "result" in resp, resp + assert resp["result"]["stat"] == " main.py | 2 +-" + assert "+print('v3')" in resp["result"]["diff"] + assert resp["result"]["empty"] is False + + +def test_diff_session_reports_empty_when_no_changes(): + """A clean tree yields empty=True so the UI can show a friendly message.""" + + class _Mgr: + enabled = True + + def session_diff(self, cwd): + return {"success": True, "stat": "", "diff": "", "empty": True} + + server._sessions["sid"] = _session( + agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[] + ) + try: + resp = server.handle_request( + {"id": "1", "method": "diff.session", "params": {"session_id": "sid"}} + ) + finally: + server._sessions.pop("sid", None) + + assert "result" in resp, resp + assert resp["result"]["empty"] is True + assert resp["result"]["diff"] == "" + + # ── session.steer ──────────────────────────────────────────────────── diff --git a/tests/tools/test_checkpoint_manager.py b/tests/tools/test_checkpoint_manager.py index 5c6db10c0119..d8e4025a026a 100644 --- a/tests/tools/test_checkpoint_manager.py +++ b/tests/tools/test_checkpoint_manager.py @@ -1049,3 +1049,53 @@ def test_clear_all_on_missing_base_is_noop(self, tmp_path, monkeypatch): result = clear_all() assert result["deleted"] is False assert result["bytes_freed"] == 0 + + +# ========================================================================= +# session_diff - cumulative "what changed" view that powers /diff +# ========================================================================= + +class TestSessionDiff: + def test_no_checkpoints_is_empty_success(self, mgr, work_dir): + """With nothing edited yet, session_diff succeeds and reports empty.""" + result = mgr.session_diff(str(work_dir)) + assert result["success"] is True + assert result.get("empty") is True + assert result["diff"] == "" + + def test_cumulative_diff_spans_all_edits(self, mgr, work_dir): + """The diff covers the first edit through the latest working tree.""" + # First checkpoint captures the pre-edit state (main.py == hello). + mgr.ensure_checkpoint(str(work_dir), "before edit 1") + (work_dir / "main.py").write_text("print('v2')\n") + mgr.new_turn() + mgr.ensure_checkpoint(str(work_dir), "before edit 2") + (work_dir / "main.py").write_text("print('v3')\n") + + result = mgr.session_diff(str(work_dir)) + assert result["success"] is True + assert not result.get("empty") + # Baseline is the earliest retained checkpoint. + assert result["baseline"] == mgr.list_checkpoints(str(work_dir))[-1]["hash"] + # Cumulative: the original line is removed, the final line added; the + # intermediate "v2" is neither in the baseline nor the working tree. + assert "-print('hello')" in result["diff"] + assert "+print('v3')" in result["diff"] + assert "v2" not in result["diff"] + + def test_includes_newly_added_files(self, mgr, work_dir): + mgr.ensure_checkpoint(str(work_dir), "baseline") + (work_dir / "feature.py").write_text("x = 1\n") + + result = mgr.session_diff(str(work_dir)) + assert result["success"] is True + assert "feature.py" in result["diff"] + assert "+x = 1" in result["diff"] + + def test_no_changes_since_baseline_reports_empty(self, mgr, work_dir): + """A checkpoint with no subsequent edits yields an empty diff.""" + mgr.ensure_checkpoint(str(work_dir), "baseline") + result = mgr.session_diff(str(work_dir)) + assert result["success"] is True + assert result.get("empty") is True + assert result["diff"] == "" diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index 720973b67e0b..5b1353bcf95a 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -784,6 +784,33 @@ def diff(self, working_dir: str, commit_hash: str) -> Dict: "diff": diff_out if ok_diff else "", } + def session_diff(self, working_dir: str) -> Dict: + """Show the cumulative diff of everything changed in this directory. + + This powers the ``/diff`` command. It answers "what has Hermes + changed here?" by diffing the *earliest retained checkpoint* - the + snapshot taken before the first recorded edit - against the current + working tree. Because checkpoints are captured just before each + file-mutating tool call, that baseline is the pre-edit state, so the + diff covers the first edit and everything after it. + + Returns the same shape as :meth:`diff` (``{"success", "stat", + "diff"}``). When no checkpoints exist yet - nothing has been edited - + the call still *succeeds* with empty output and ``"empty": True`` so + callers can show a friendly "no changes" message rather than an error. + """ + checkpoints = self.list_checkpoints(working_dir) + if not checkpoints: + return {"success": True, "stat": "", "diff": "", "empty": True} + + baseline = checkpoints[-1].get("hash") or "" + result = self.diff(working_dir, baseline) + if result.get("success"): + result.setdefault("baseline", baseline) + if not result.get("stat") and not result.get("diff"): + result["empty"] = True + return result + def restore(self, working_dir: str, commit_hash: str, file_path: str = None) -> Dict: """Restore files to a checkpoint state.""" hash_err = _validate_commit_hash(commit_hash) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 826efc9faa2f..b99302d5d2d0 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -12743,6 +12743,34 @@ def _(rid, params: dict) -> dict: return _err(rid, 5022, str(e)) +@method("diff.session") +def _(rid, params: dict) -> dict: + """Cumulative diff of everything changed since the earliest checkpoint. + + Backs the ``/diff`` command - the session-wide counterpart to + ``rollback.diff`` (which previews a single chosen checkpoint). + """ + session, err = _sess(params, rid) + if err: + return err + try: + r = _with_checkpoints(session, lambda mgr, cwd: mgr.session_diff(cwd)) + if not r.get("success"): + return _err(rid, 5023, r.get("error", "Could not generate diff")) + raw = r.get("diff", "")[:4000] + payload = { + "stat": r.get("stat", ""), + "diff": raw, + "empty": bool(r.get("empty")), + } + rendered = render_diff(raw, session.get("cols", 80)) + if rendered: + payload["rendered"] = rendered + return _ok(rid, payload) + except Exception as e: + return _err(rid, 5023, str(e)) + + # ── Methods: browser / plugins / cron / skills ─────────────────────── diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 6eca760d434e..44194cd9282c 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -45,6 +45,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/title` | Set a title for the current session (usage: /title My Session Name) | | `/compress [here [N] \| focus topic]` | Manually compress conversation context (flush memories + summarize). `/compress here [N]` summarizes everything except the most recent N exchanges (default 2), kept verbatim — pick your own compression boundary. A focus topic narrows what a full summary preserves. | | `/rollback` | List or restore filesystem checkpoints (usage: /rollback [number]) | +| `/diff [--stat]` | Show everything Hermes has changed in the working directory this session: a cumulative git diff from the earliest retained checkpoint (the pre-edit baseline) to the current files. Unlike `/rollback diff ` (which previews one chosen checkpoint), `/diff` answers "what has Hermes changed here?" in a single view. `--stat` prints just the changed-file summary. Requires checkpoints to be enabled. | | `/snapshot [create\|restore \|prune]` (alias: `/snap`) | Create or restore state snapshots of Hermes config/state. `create [label]` saves a snapshot, `restore ` reverts to it, `prune [N]` removes old snapshots, or list all with no args. | | `/stop` | Kill all running background processes | | `/queue ` (alias: `/q`) | Queue a prompt for the next turn (doesn't interrupt the current agent response). | @@ -221,6 +222,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/reasoning [level\|show\|hide]` | Change reasoning effort or toggle reasoning display. | | `/voice [on\|off\|tts\|join\|channel\|leave\|status]` | Control spoken replies in chat. `join`/`channel`/`leave` manage Discord voice-channel mode. | | `/rollback [number]` | List or restore filesystem checkpoints. | +| `/diff [--stat]` | Show the cumulative diff of everything Hermes changed in the working directory this session. `--stat` shows just the summary. | | `/background ` | Run a prompt in a separate background session. Results are delivered back to the same chat when the task finishes. See [Messaging Background Sessions](/user-guide/messaging/#background-sessions). | | `/queue ` (alias: `/q`) | Queue a prompt for the next turn without interrupting the current one. | | `/steer ` | Inject a message after the next tool call without interrupting — the model picks it up on its next iteration rather than as a new turn. | @@ -250,7 +252,7 @@ The messaging gateway supports the following built-in commands inside Telegram, - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/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. - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands. -- `/status`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. +- `/status`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. - In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume ` for saved or closed transcripts.