"""Slash-command handlers for the interactive CLI (``HermesCLI`` inherits ``CLICommandsMixin``).

cli.py-internal symbols (``_cprint``/``_ACCENT``/``save_config_value``…) are imported LAZILY inside
the helpers/handlers via ``from cli import ...`` — cli.py imports this module (cycle otherwise).
"""

from __future__ import annotations

import argparse
import atexit
import importlib
import io
import json
import os
import shlex
import subprocess
import tempfile
import threading
import time
import uuid
from contextlib import redirect_stdout, suppress
from io import StringIO
from datetime import datetime
from urllib.parse import urlparse

from rich import box as rich_box
from rich.markup import escape as _escape
from rich.panel import Panel

from hermes_constants import display_hermes_home
from hermes_state_ids import new_session_id as mint_session_id
from agent.i18n import t
from agent.message_metadata import message_identity
from agent.turn_context import extract_api_content_sidecar
from hermes_cli.cli_agent_setup_mixin import _retire_agent
from hermes_cli.browser_connect import (
    DEFAULT_BROWSER_CDP_URL, discover_local_cdp_url, find_free_debug_port, is_browser_debug_ready,
    launch_chrome_debug, local_port_in_use, manual_chrome_debug_command)


# Output helpers. Slash-command text is user-visible: every literal below is load-bearing.
def _cp(*lines: str) -> None:
    """``_cprint`` each line (lazy import: cli.py imports this module)."""
    from cli import _cprint
    for line in lines:
        _cprint(line)


def _pr(*lines: str) -> None:
    """print() each line."""
    for line in lines:
        print(line)


def _save(key: str, value) -> bool:
    """cli.save_config_value, resolved lazily (cli.py imports this module)."""
    from cli import save_config_value
    return save_config_value(key, value)


def _dim(text: str) -> str:
    """Wrap ``text`` in the dim ANSI escape."""
    from cli import _DIM, _RST
    return f"{_DIM}{text}{_RST}"


def _dim_line(text: str) -> str:
    """Two-space indented dim line (the standard slash-command hint shape)."""
    return "  " + _dim(text)


def _accent(text: str) -> str:
    """Wrap ``text`` in the skin accent ANSI escape."""
    from cli import _ACCENT, _RST
    return f"{_ACCENT}{text}{_RST}"


def _accent_line(text: str) -> str:
    """Two-space indented accent line (the standard slash-command headline shape)."""
    return f"  {_accent(text)}"


def _t(key: str, **kwargs) -> str:
    """Catalog text for this module: ``cli.commands.<key>`` in the active language."""
    return t(f"cli.commands.{key}", **kwargs)


def _tn(key: str, count: int, **kwargs) -> str:
    """Plural-aware ``_t``: ``<key>_one`` when ``count == 1``, else ``<key>_other``."""
    return _t(f"{key}_{'one' if count == 1 else 'other'}", count=count, **kwargs)


def _gt(key: str, **kwargs) -> str:
    """A ``gateway.<key>`` catalog entry the CLI shares verbatim with the messaging gateway."""
    return t(f"gateway.{key}", **kwargs)


def _lines(text: str, pad: str = "  ") -> list:
    """Each line of a (possibly multi-line) catalog value prefixed with ``pad`` (blank lines kept)."""
    return [f"{pad}{line}" if line else "" for line in text.splitlines()]


def _probe(module: str, name: str, default, *args):
    """``<module>.<name>(*args)`` or ``default`` when the import or the call fails (optional
    subsystems: browser backends, async delegations, wake word, ...)."""
    try:
        return getattr(importlib.import_module(module), name)(*args)
    except Exception:
        return default


class _TTYBuf(StringIO):
    """StringIO that claims to be a TTY so ``hermes_cli.colors.color()`` still emits ANSI escapes."""
    def isatty(self) -> bool:
        return True


_FAILED = object()


def _attempt(label: str, errors, fn, *args, **kwargs):
    """``fn(*args, **kwargs)``, or ``_FAILED`` after printing ``  <label>: <exc>`` for ``errors``."""
    try:
        return fn(*args, **kwargs)
    except errors as exc:
        _cp(f"  {label}: {exc}")
        return _FAILED


def _say_block(*lines: str) -> None:
    """print() the lines framed by a blank line above and below (the /browser output style)."""
    _pr("", *lines, "")


def _command_arg(cmd: str, *, lower: bool = False) -> str:
    """Everything after the slash-command word, stripped (optionally lowercased)."""
    parts = (cmd or "").strip().split(None, 1)
    arg = parts[1].strip() if len(parts) > 1 else ""
    return arg.lower() if lower else arg


def _shlex_args(cmd: str) -> list:
    """Tokens after the command word; falls back to whitespace split on unbalanced quotes."""
    try:
        return shlex.split(cmd)[1:] if cmd else []
    except ValueError:
        return (cmd or "").split()[1:]


def _take_flag(parts: list, flag: str):
    """Pop ``flag VALUE`` out of ``parts``: ``(rest, value, ok)``; ok=False when the value is
    missing (caller prints usage)."""
    if flag not in parts:
        return parts, None, True
    idx = parts.index(flag)
    if idx + 1 >= len(parts):
        return parts, None, False
    return parts[:idx] + parts[idx + 2:], parts[idx + 1], True


def _summarize_paths(paths, limit: int = 5) -> str:
    """``a, b, c (+N more)`` for a list of paths."""
    more = _t("shared.more_suffix", count=len(paths) - limit) if len(paths) > limit else ""
    return ", ".join(paths[:limit]) + more


def _ellipsize(text: str, limit: int) -> str:
    """``text[:limit]`` plus ``...`` when truncated."""
    return f"{text[:limit]}{'...' if len(text) > limit else ''}"


# Small data tables.

# /cron flag tables: flag -> opts key. Order-sensitive in _parse_cron_flags: bool flags never
# consume a value; --repeat is int-validated separately.
_CRON_BOOL_FLAGS = {"--clear-skills": "clear_skills", "--all": "all"}
_CRON_LIST_FLAGS = {"--skill": "skills", "--add-skill": "add_skills", "--remove-skill": "remove_skills"}
_CRON_VALUE_FLAGS = {"--name": "name", "--deliver": "deliver", "--prompt": "prompt", "--schedule": "schedule"}
# /cron subcommand -> CLICommandsMixin method name.
_CRON_SUBCOMMANDS = {
    "list": "_cron_list", "add": "_cron_add", "create": "_cron_add", "edit": "_cron_edit",
    **{k: "_cron_job_action" for k in ("pause", "resume", "run", "remove", "rm", "delete")}}

_ON_WORDS = {"on", "enable", "true", "1"}
_OFF_WORDS = {"off", "disable", "false", "0"}

# /busy modes; what Enter does while Hermes is working lives in the catalog as
# ``cli.commands.busy.short_<mode>`` (status line) / ``long_<mode>`` (post-set explanation).
_BUSY_MODES = ("queue", "steer", "interrupt")

# /fast argument -> (service_tier value, persisted config value)
_FAST_TIERS = {
    "fast": ("priority", "fast"), "on": ("priority", "fast"), "normal": (None, "normal"),
    "off": (None, "normal"), "auto": ("auto", "auto"), "cold": ("cold", "cold"),
    "ultrafast": ("ultrafast", "ultrafast")}

# /reasoning display toggles: arg -> (attr, value, headline key, follow-up note key or None);
# the keys resolve under ``cli.commands.reasoning.*`` at call time.
_REASONING_TOGGLES = {
    **dict.fromkeys(("show", "on"), ("show_reasoning", True, "headline_on", "note_show")),
    **dict.fromkeys(("hide", "off"), ("show_reasoning", False, "headline_off", None)),
    **dict.fromkeys(("full", "all"), ("reasoning_full", True, "headline_full", "note_full")),
    **dict.fromkeys(("clamp", "collapse", "short"), ("reasoning_full", False, "headline_clamped", None)),
}

# /bg AIAgent provider-routing kwargs -> HermesCLI attribute carrying the value.
_BG_PROVIDER_KWARGS = {
    "providers_allowed": "_providers_only", "providers_ignored": "_providers_ignore",
    "providers_order": "_providers_order", "provider_sort": "_provider_sort",
    "provider_require_parameters": "_provider_require_params",
    "provider_data_collection": "_provider_data_collection",
    "openrouter_min_coding_score": "_openrouter_min_coding_score", "fallback_model": "_fallback_model"}

# /worktree subcommand -> CLICommandsMixin method name (all need a repo root).
_WORKTREE_SUBCOMMANDS = {
    **dict.fromkeys(("prune", "gc", "clean"), "_worktree_prune"),
    **dict.fromkeys(("list", "ls"), "_worktree_list"),
    **dict.fromkeys(("new", "add", "create"), "_worktree_new")}

# Message fields copied verbatim onto a /branch row (plus role / tool_name / api_content).
_BRANCH_COPY_KEYS = ("content", "tool_calls", "tool_call_id", "reasoning", "reasoning_details",
                     "codex_reasoning_items", "codex_message_items", "timestamp")

# /hatch progress event -> catalog key (``cli.commands.hatch.*``).
_HATCH_PROGRESS = {"compose": "hatch.progress_compose", "save": "hatch.progress_save"}

# /diff argument -> mode (anything else is a path; --stat/stat is the stat flag).
_DIFF_MODES = {
    "staged": "staged", "--staged": "staged", "cached": "staged", "--cached": "staged",
    "all": "all", "--all": "all", "head": "all", "session": "session"}
# /diff mode -> stat headline catalog key (``cli.commands.diff.*``).
_DIFF_LABELS = {"working": "diff.label_working", "staged": "diff.label_staged", "all": "diff.label_all"}


def _persist_display_choice(key: str, value: str, label: str, note: str) -> None:
    """Save a /busy-style choice to config and report saved vs session-only."""
    if _save(key, value):
        _cp(_accent_line(_t("shared.set_saved", label=label, value=value)), _dim_line(note))
    else:
        _cp(_accent_line(_t("shared.set_session_only", label=label, value=value)))


def _split_scope_flags(raw: str):
    """``(arg, explicit_global)`` for /reasoning + /fast: session scope by default, ``--global``
    persists to config.yaml, ``--session`` is an explicit no-op (parity with /model)."""
    tokens = raw.strip().lower().split()
    return " ".join(t for t in tokens if t not in ("--global", "--session")), "--global" in tokens


def _scope_outcome(explicit_global: bool, saved: bool) -> str:
    """Parenthetical tail for a scoped setting change."""
    if saved:
        return _t("shared.scope_saved")
    if explicit_global:
        return _t("shared.scope_save_failed")
    return _t("shared.scope_session")


def _toggle_target(arg: str, current: bool):
    """Resolve a ``/x [on|off|status]`` argument: "status" for a status query, a bool for the
    new state (bare arg toggles), or None when the argument is unrecognized."""
    if arg in {"status", "?"}:
        return "status"
    if arg in _ON_WORDS:
        return True
    if arg in _OFF_WORDS:
        return False
    if arg == "":
        return not current
    return None


def _cron_api(**kwargs) -> dict:
    """Call the cronjob model tool and decode its JSON reply."""
    from tools.cronjob_tools import cronjob as cronjob_tool
    return json.loads(cronjob_tool(**kwargs))


def _normalize_skills(values) -> list:
    """Strip, drop empties, and dedupe (order-preserving)."""
    normalized = []
    for value in values:
        text = str(value or "").strip()
        if text and text not in normalized:
            normalized.append(text)
    return normalized


def _parse_cron_flags(tokens):
    """Parse /cron flags into an opts dict (None after printing an error for a bad --repeat)."""
    opts = {
        "name": None, "deliver": None, "repeat": None, "prompt": None, "schedule": None,
        "skills": [], "add_skills": [], "remove_skills": [],
        "clear_skills": False, "all": False, "positionals": []}
    i = 0
    while i < len(tokens):
        token = tokens[i]
        has_value = i + 1 < len(tokens)
        if token in _CRON_BOOL_FLAGS:
            opts[_CRON_BOOL_FLAGS[token]] = True
            i += 1
        elif token in _CRON_LIST_FLAGS and has_value:
            opts[_CRON_LIST_FLAGS[token]].append(tokens[i + 1])
            i += 2
        elif token == "--repeat" and has_value:
            try:
                opts["repeat"] = int(tokens[i + 1])
            except ValueError:
                return print(_t("cron.repeat_must_be_integer"))
            i += 2
        elif token in _CRON_VALUE_FLAGS and has_value:
            opts[_CRON_VALUE_FLAGS[token]] = tokens[i + 1]
            i += 2
        else:
            opts["positionals"].append(token)
            i += 1
    return opts


# Session-switch plumbing shared by /resume and /branch.
def _end_current_session(cli, reason: str) -> None:
    """Flush un-persisted messages, then end the current session row with ``reason``.
    Best-effort on both steps (the switch proceeds even if the DB write fails)."""
    if cli.agent:
        with suppress(Exception):
            cli.agent._flush_messages_to_session_db(
                cli.conversation_history, conversation_history=cli.conversation_history)
    with suppress(Exception):
        cli._session_db.end_session(cli.session_id, reason)


def _sync_agent_to_session(cli, session_id: str, *, parent_session_id: str, reason: str) -> None:
    """Point an already-built agent at ``session_id`` after a /resume or /branch switch: reset
    per-session state, re-anchor the DB flush index, and notify memory providers with
    reset=False (their state stays valid and just targets the new id; parent keeps lineage)."""
    if not cli.agent:
        return
    cli.agent.session_id = session_id
    cli.agent.reset_session_state()
    if hasattr(cli.agent, "_last_flushed_db_idx"):
        cli.agent._last_flushed_db_idx = len(cli.conversation_history)
    if hasattr(cli.agent, "_todo_store"):
        with suppress(Exception):
            from tools.todo_tool import TodoStore
            cli.agent._todo_store = TodoStore()
    if hasattr(cli.agent, "_invalidate_system_prompt"):
        cli.agent._invalidate_system_prompt()
    with suppress(Exception):
        _mm = getattr(cli.agent, "_memory_manager", None)
        # Notify memory providers that session_id rotated to a fresh conversation. reset=True signals
        # providers to flush accumulated per-session state (_session_turns, _turn_counter, _document_id).
        # Fires BEFORE the plugin on_session_reset hook (shell hooks only see the new id; Python providers
        # see the transition). See #6672. When the old session has history, end-of-session extraction
        # (LLM-bound, seconds) and this switch are queued as ONE task on the memory manager's serialized
        # worker — end strictly before switch, without blocking /new (#16454). With no history there is
        # nothing to extract; switch inline as before.
        # Notify memory providers that session_id rotated to a resumed session. reset=False — the provider's
        # accumulated state is still valid; it just needs to target the new session_id for subsequent
        # writes. See #6672.
        # Notify memory providers that session_id forked to a new branch. reset=False — the branched session
        # carries the transcript forward, so provider state tracks the lineage. parent_session_id links the
        # branch back to the original. See #6672.
        if _mm is not None:
            _mm.on_session_switch(
                session_id, parent_session_id=parent_session_id or "", reset=False, reason=reason)


def _without_session_meta(messages) -> list:
    return [m for m in (messages or []) if m.get("role") != "session_meta"]


def _db_unavailable_line() -> str:
    from hermes_state import format_session_db_unavailable
    return f"  {format_session_db_unavailable(details=True)}"


def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_note, console=None) -> None:
    """Print a worker-thread result (/bg, /btw, /login) into the scrollback: accent rules around
    ``header_lines``, then ``body`` in a skinned Rich panel (or ``empty_note``).
    Forces a TUI refresh first so the spinner/status bar don't overlap the output."""
    from cli import ChatConsole, _accent_hex, _maybe_remap_for_light_mode, _render_final_assistant_content
    _refresh_tui_before_print(cli)
    rich_console = console or ChatConsole()
    rich_console.print(f"[{_accent_hex()}]{'─' * 40}[/]")
    if console is None:
        _cp(*header_lines)
    else:
        for line in header_lines:
            console.print(line)
    rich_console.print(f"[{_accent_hex()}]{'─' * 40}[/]")
    if not body:
        return _cp(empty_note) if console is None else console.print(empty_note)
    try:
        from hermes_cli.skin_engine import get_active_skin
        _skin = get_active_skin()
        label = _skin.get_branding("response_label", "☤ Hermes")
        _resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32"))
        _resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
    except Exception:
        label, _resp_color, _resp_text = "☤ Hermes", "#CD7F32", "#FFF8DC"
    rich_console.print(Panel(
        _render_final_assistant_content(body, mode=cli.final_response_markdown),
        title=f"[{_resp_color} bold]{label} {title_suffix}[/]", title_align="left",
        border_style=_resp_color, style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4),
        width=cli._scrollback_box_width()))


def _refresh_tui_before_print(cli) -> None:
    """Invalidate the running TUI (brief pause for the redraw) then print a blank separator, so
    worker-thread output doesn't overlap the spinner/status bar."""
    if cli._app:
        cli._app.invalidate()
        time.sleep(0.05)
    print()


# /browser sub-handlers.
def _print_lightpanda_engine_status() -> None:
    """``/browser status`` line(s) about ``browser.engine: lightpanda`` — silent unless set;
    says whether it is in use or which higher-precedence setting shadows it."""
    if not _probe("tools.browser_tool_lightpanda_fallback", "_using_lightpanda_engine", False):
        return
    used, reason = _probe("tools.browser_tool_lightpanda_fallback", "lightpanda_engine_status", (None, None))
    if reason is None:
        return
    if not used:
        return print(f"   {_t('browser.engine_not_in_use', reason=reason)}")
    print(f"   {_t('browser.engine_lightpanda', reason=reason)}")
    try:
        from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary
        lightpanda_bin = find_lightpanda_binary()
    except Exception:
        return
    print(f"   {_t('browser.binary', path=lightpanda_bin)}" if lightpanda_bin
          else f"   {_t('browser.binary_not_found', hint=LIGHTPANDA_INSTALL_HINT)}")


def _browser_use(cli, arg: str) -> None:
    """/browser use [off] — toggle Browser Use mode (browser.backend); resets the session."""
    from hermes_cli.config import load_config, save_config
    from tools.registry import invalidate_check_fn_cache
    if arg not in {"on", "off"}:
        return _say_block(
            _t("browser.use_usage"),
            f"   {_t('browser.use_on_hint')}", f"   {_t('browser.use_off_hint')}")
    config = load_config()
    if arg == "on":
        config.setdefault("browser", {})["backend"] = "browser-use"
        headline = _t("browser.use_enabled")
    else:
        from tools.browser_use_cli import BACKEND_DISABLED
        config.setdefault("browser", {})["backend"] = BACKEND_DISABLED
        headline = _t("browser.use_disabled")
    save_config(config)
    invalidate_check_fn_cache()
    cli.new_session()
    _say_block(headline, f"   {_t('browser.session_reset')}")


def _normalize_cdp_url(cdp_url: str):
    """Validate a /browser connect URL: ``(cdp_url, port)`` or None after printing the error.
    A ``/devtools/browser/<id>`` path is kept verbatim; anything else is reduced to the origin."""
    parsed = urlparse(cdp_url if "://" in cdp_url else f"http://{cdp_url}")
    if parsed.scheme not in {"http", "https", "ws", "wss"}:
        _say_block(f"   {_t('browser.unsupported_scheme', scheme=parsed.scheme or _t('browser.scheme_missing'))}")
        return None
    try:
        port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80)
    except ValueError:
        _say_block(f"   {_t('browser.invalid_port', url=cdp_url)}")
        return None
    if not parsed.hostname:
        _say_block(f"   {_t('browser.missing_host', url=cdp_url)}")
        return None
    if parsed.path.startswith("/devtools/browser/"):
        return parsed.geturl(), port
    return parsed._replace(path="", params="", query="", fragment="").geturl(), port


def _launch_default_cdp_browser(port: int):
    """Launch a local debug browser for the default CDP URL; returns the discovered CDP url or
    None (after printing what went wrong / how to launch manually)."""
    import platform as _plat
    launch_port = port
    if local_port_in_use(port):
        launch_port = find_free_debug_port(port)
        _pr(f"   {_t('browser.port_occupied', port=port)}",
            f"     {_t('browser.port_occupied_detail', launch_port=launch_port)}")
    else:
        print(f"   {_t('browser.launching')}")
    launch = launch_chrome_debug(launch_port, _plat.system())
    if not launch.launched:
        print(f"   {_t('browser.launch_failed')}")
        if launch.hint:
            print(f"     {launch.hint}")
        chrome_cmd = manual_chrome_debug_command(launch_port, _plat.system())
        if chrome_cmd:
            _pr(f"     {_t('browser.launch_manually')}", f"     {chrome_cmd}")
        else:
            print(f"     {_t('browser.no_executable')}")
        return None
    for _wait in range(10):  # wait for the DevTools discovery endpoint to come up
        found = discover_local_cdp_url(launch_port, timeout=1.0)
        if found:
            print(f"   {_t('browser.launched', port=launch_port)}")
            return found
        time.sleep(0.5)
    _pr(f"   {_t('browser.not_responding', port=launch_port)}", f"     {_t('browser.try_again')}")
    return None


def _browser_connect(cli, cdp_url: str) -> None:
    """/browser connect [url] — validate the CDP URL, find or launch a debug browser, then
    point the browser tools at it (BROWSER_CDP_URL) and tell the model."""
    normalized = _normalize_cdp_url(cdp_url)
    if normalized is None:
        return
    cdp_url, port = normalized
    # Clear any existing browser sessions so the next tool call uses the new backend
    _probe("tools.browser_tool_lifecycle", "cleanup_all_browsers", None)
    print()
    # Already serving CDP? For the default-local URL probe both loopbacks: a squatter
    # on 127.0.0.1:<port> (e.g. an IDE debugger) can push the browser to bind [::1] only.
    is_default = cdp_url == DEFAULT_BROWSER_CDP_URL
    if is_default:
        found = discover_local_cdp_url(port, timeout=1.0)
    else:
        found = cdp_url if is_browser_debug_ready(cdp_url, timeout=1.0) else None
    if found:
        print(f"   {_t('browser.already_listening', url=found)}")
    elif is_default:
        found = _launch_default_cdp_browser(port)
    else:
        print(f"   {_t('browser.port_unreachable', port=port, url=cdp_url)}")
    if not found:
        return _say_block(_t("browser.not_connected"))
    os.environ["BROWSER_CDP_URL"] = found
    # Eagerly start the CDP supervisor so pending_dialogs + frame_tree show up in the next snapshot.
    _probe("tools.browser_tool_cdp", "_ensure_cdp_supervisor", None, "default")
    _say_block(_t("browser.connected"), f"   {_t('browser.endpoint', url=found)}")
    # Tell the model the CDP browser was made available on purpose.
    if hasattr(cli, '_pending_input'):
        cli._pending_input.put(
            "[System note: The user invoked /browser connect and connected your browser tools to "
            "a Chromium-family dev/debug browser via Chrome DevTools Protocol. "
            "Your browser_navigate, browser_snapshot, browser_click, and other browser tools now "
            "control that CDP browser. The command itself is a signal that using browser tools for "
            "their current browser-related request is expected; do not wait for separate permission "
            "just because CDP is connected. This is typically a Hermes-managed isolated debug "
            "profile, not the user's main everyday browser. It is still user-visible and may contain "
            "pages, logged-in sessions, or cookies in that debug profile, so avoid destructive actions, "
            "closing tabs, or navigating away unless the user's task calls for it.]")


def _browser_disconnect(cli) -> None:
    if not os.environ.get("BROWSER_CDP_URL", "").strip():
        return _say_block(_t("browser.already_default"))
    os.environ.pop("BROWSER_CDP_URL", None)
    with suppress(Exception):
        from tools.browser_tool_lifecycle import cleanup_all_browsers
        from tools.browser_tool_cdp import _stop_cdp_supervisor
        _stop_cdp_supervisor("default")
        cleanup_all_browsers()
    _say_block(_t("browser.disconnected"), f"   {_t('browser.reverted')}")
    if hasattr(cli, '_pending_input'):
        cli._pending_input.put(
            "[System note: The user has disconnected the browser tools from their live Chromium-family browser. "
            "Browser tools are back to default mode (headless local browser or cloud provider).]")


# /browser status headline per local browser.engine value: (headline key, *detail keys) under
# ``cli.commands.browser.*``; detail lines are indented at print time.
_LOCAL_ENGINE_LINES = {
    "lightpanda": ("status_lightpanda", "status_lightpanda_note", "status_lightpanda_fallback"),
    "chrome": ("status_chrome",),
    "auto": ("status_auto",)}


def _local_engine_lines(engine: str) -> list:
    head, *details = _LOCAL_ENGINE_LINES.get(engine, _LOCAL_ENGINE_LINES["auto"])
    return [_t(f"browser.{head}"), *(f"   {_t(f'browser.{key}')}" for key in details)]


def _browser_status() -> None:
    current = os.environ.get("BROWSER_CDP_URL", "").strip()
    print()
    if _probe("tools.browser_use_cli", "is_browser_use_cli_mode", False):
        _pr(_t("browser.status_browser_use"), f"   {_t('browser.status_browser_use_note')}")
        _print_lightpanda_engine_status()
        return _say_block(f"   {_t('browser.status_use_off_hint')}")
    if current:
        _pr(_t("browser.status_connected"), f"   {_t('browser.endpoint', url=current)}")
        _print_lightpanda_engine_status()
        _port = 9222
        with suppress(ValueError, IndexError):
            _port = int(current.rsplit(":", 1)[-1].split("/")[0])
        try:
            import socket
            socket.create_connection(("127.0.0.1", _port), timeout=1).close()
            print(f"   {_t('browser.status_reachable')}")
        except Exception:
            print(f"   {_t('browser.status_unreachable')}")
    else:
        provider = _probe("tools.browser_tool_cloud", "_get_cloud_provider", None)
        if provider is not None:
            print(_t("browser.status_cloud", provider=provider.display_name))
            _print_lightpanda_engine_status()
        else:
            engine = _probe("tools.browser_tool_cloud", "_get_browser_engine", "auto")
            _pr(*_local_engine_lines(engine))
            if engine == "lightpanda":
                _print_lightpanda_engine_status()
    _say_block(f"   {_t('browser.status_connect_hint')}", f"   {_t('browser.status_disconnect_hint')}")


# /browser subcommand word → handler(cli, rest); ``rest`` is the raw (case-preserved)
# remainder of the line. Adding a subcommand is one row here plus a usage line.
_BROWSER_SUBCOMMANDS = {
    "use": lambda cli, rest: _browser_use(cli, rest.lower() or "on"),
    "connect": lambda cli, rest: _browser_connect(cli, rest or DEFAULT_BROWSER_CDP_URL),
    "disconnect": lambda cli, rest: _browser_disconnect(cli),
    "status": lambda cli, rest: _browser_status(),
}


class CLICommandsMixin:
    """Mixin holding the interactive-CLI slash-command handlers."""

    # ---- /rollback ------------------------------------------------------------------------
    def _checkpoint_manager(self, disabled_lines):
        """The agent's checkpoint manager, or None after printing why it is unavailable."""
        if not hasattr(self, 'agent') or not self.agent:
            return print(f"  {_t('shared.no_active_agent_session')}")
        mgr = self.agent._checkpoint_mgr
        if not mgr.enabled:
            return _pr(*disabled_lines)
        return mgr

    def _handle_rollback_command(self, command: str):
        """Handle /rollback [diff] <N> [<file>|--all] — list, diff, or restore checkpoints.
        A restore also undoes the last chat turn; ``--all`` overwrites user hand-edits too."""
        from tools.checkpoint_manager import format_checkpoint_list
        mgr = self._checkpoint_manager(_lines(_t("rollback.not_enabled")))
        if mgr is None:
            return
        cwd = os.getenv("TERMINAL_CWD", os.getcwd())
        args = command.split()[1:]
        # --all / --force: classic full restore, overwriting user edits too.
        restore_all = any(a.lower() in ("--all", "--force") for a in args)
        args = [a for a in args if a.lower() not in ("--all", "--force")]
        if reason := mgr.unsupported_backend_reason():  # CLI: no session key, the "default" container
            # Container-backed session: any host checkpoint listed here belongs to another tree,
            # so diff/restore are refused; the list stays visible for local administration.
            print(f"  {reason}")
            if args:
                return
        if not args:
            # No checkpoints for this dir → cross-project view (writes may sit under the session cwd).
            checkpoints = mgr.list_checkpoints(cwd)
            if not checkpoints:
                # List checkpoints — fall back to the cross-project view when the current directory has none
                # (#10505, reapply of PR #10633 by @nightq). The Aug 2026 QA sweep hit this live: writes
                # landed checkpoints under the session cwd (/tmp/qa-repo) while bare /rollback searched only
                # TERMINAL_CWD's project and reported "No checkpoints found" despite fresh checkpoints
                # existing.
                all_checkpoints = mgr.list_all_checkpoints()
                if all_checkpoints:
                    print(f"  {_t('rollback.none_here_showing_all', cwd=cwd)}")
                    return print(format_checkpoint_list(all_checkpoints, _t("rollback.all_directories")))
            return print(format_checkpoint_list(checkpoints, cwd))
        is_diff = args[0].lower() == "diff"
        if is_diff and len(args) < 2:
            return print(f"  {_t('rollback.usage_diff')}")
        checkpoints = mgr.list_checkpoints(cwd)
        if not checkpoints:
            return print(f"  {_gt('rollback.none_found', cwd=cwd)}")
        target_hash = self._resolve_checkpoint_ref(args[1 if is_diff else 0], checkpoints)
        if not target_hash:
            return
        if is_diff:
            self._rollback_diff(mgr, cwd, target_hash)
        else:
            file_path = args[1] if len(args) > 1 else None
            self._rollback_restore(mgr, cwd, target_hash, file_path, restore_all)

    def _rollback_diff(self, mgr, cwd: str, target_hash: str) -> None:
        result = mgr.diff(cwd, target_hash)
        if not result["success"]:
            return print(f"  ❌ {result['error']}")
        stat, diff = result.get("stat", ""), result.get("diff", "")
        if not stat and not diff:
            return print(f"  {_t('rollback.no_changes_since')}")
        if stat:
            print(f"\n{stat}")
        if diff:
            # Limit diff output to avoid terminal flood
            diff_lines = diff.splitlines()
            if len(diff_lines) > 80:
                _pr("\n".join(diff_lines[:80]),
                    f"\n  {_t('rollback.more_lines', count=len(diff_lines) - 80)}")
            else:
                print(f"\n{diff}")

    def _rollback_restore(self, mgr, cwd, target_hash: str, file_path, restore_all: bool) -> None:
        result = mgr.restore(cwd, target_hash, file_path=file_path,
                             safe=not restore_all and not file_path)
        if not result["success"]:
            return print(f"  ❌ {result['error']}")
        restored = dict(hash=result["restored_to"], reason=result["reason"])
        print(f"  {_t('rollback.restored_file', file=file_path, **restored)}" if file_path
              else f"  {_t('rollback.restored_all', **restored)}")
        skipped = result.get("skipped_user_edits") or []
        if skipped:
            _pr(*_lines(_gt("rollback.kept_user_edits", files=_summarize_paths(skipped))))
        oversize = result.get("skipped_oversize") or []
        if oversize:
            print(f"  {_gt('rollback.kept_oversize', files=_summarize_paths(oversize))}")
        failed = result.get("failed_deletes") or []
        if failed:
            print(f"  {_gt('rollback.failed_deletes', files=_summarize_paths(failed))}")
        print(f"  {_t('rollback.snapshot_saved')}")
        # Also undo the last conversation turn so the agent's context matches the restored files.
        if self.conversation_history:
            self.undo_last(prefill=False)
            print(f"  {_t('rollback.chat_turn_undone')}")

    # ---- /diff ----------------------------------------------------------------------------
    def _handle_diff_command(self, command: str):
        """Handle /diff [working|staged|all|session] [--stat] [<path>...] — git changes in the
        cwd; ``session`` is everything Hermes changed since the checkpoint baseline."""
        stat_only = False
        mode = "working"
        paths: list[str] = []
        for arg in _shlex_args(command):  # shlex preserves quoted paths
            low = arg.lower()
            if low in ("--stat", "stat"):
                stat_only = True
            elif low in _DIFF_MODES:
                mode = _DIFF_MODES[low]
            else:
                paths.append(arg)
        cwd = os.getenv("TERMINAL_CWD", os.getcwd())
        if mode == "session":
            return self._print_session_diff(cwd, stat_only)
        from tools.working_diff import collect_working_diff
        result = collect_working_diff(cwd, mode=mode, paths=paths or None)
        if not result.get("success"):
            return print(f"  {result.get('error') or _t('shared.could_not_generate_diff')}")
        stat, diff = result.get("stat", ""), result.get("diff", "")
        untracked = result.get("untracked", [])
        if result.get("empty") or (not stat and not diff and not untracked):
            return print(f"  {_gt('diff.no_changes')}")
        if stat:
            print(f"\n  {_t(_DIFF_LABELS[mode])}:")
            self._print_diff_text(stat)
        if untracked and mode in ("working", "all"):
            _pr(f"\n  {_t('diff.untracked')}", *(f"    + {rel}" for rel in untracked[:20]))
            if len(untracked) > 20:
                print(f"    {_t('diff.and_more', count=len(untracked) - 20)}")
        if diff and not stat_only:
            self._print_diff_body(diff, _t("diff.stat_hint"))

    def _print_diff_body(self, diff: str, stat_hint: str, limit: int = 400) -> None:
        """Print a diff, capped at ``limit`` lines with a pointer to the --stat form."""
        print("")
        diff_lines = diff.splitlines()
        if len(diff_lines) > limit:
            self._print_diff_text("\n".join(diff_lines[:limit]))
            print(f"\n  {_t('diff.more_lines', count=len(diff_lines) - limit, hint=stat_hint)}")
        else:
            self._print_diff_text(diff)

    def _print_session_diff(self, cwd: str, stat_only: bool):
        """Print the cumulative checkpoint-baseline diff (/diff session)."""
        mgr = self._checkpoint_manager(_lines(_t("diff.not_enabled")))
        if mgr is None:
            return
        if reason := mgr.unsupported_backend_reason():  # host baseline is not this session's tree
            return print(f"  {reason}")
        result = mgr.session_diff(cwd)
        if not result.get("success"):
            return print(f"  {result.get('error') or _t('shared.could_not_generate_diff')}")
        stat, diff = result.get("stat", ""), result.get("diff", "")
        if result.get("empty") or (not stat and not diff):
            return print(f"  {_t('diff.no_session_changes')}")
        if stat:
            self._print_diff_text(f"\n{stat}")
        if diff and not stat_only:
            self._print_diff_body(diff, _t("diff.stat_hint_session"))

    def _print_diff_text(self, text: str) -> None:
        """Render diff/stat text with color when a rich console is present; plain print otherwise
        (e.g. unit tests instantiating the mixin standalone)."""
        console = getattr(self, "console", None)
        if console is not None:
            try:
                from cli import _rich_text_from_ansi
                console.print(_rich_text_from_ansi(text))
                return
            except Exception:
                pass
        print(text)

    # ---- /snapshot ------------------------------------------------------------------------
    def _handle_snapshot_command(self, command: str):
        """Handle /snapshot [list|create [label]|restore <id>|prune [N]] — state snapshots."""
        parts = command.split()
        subcmd = parts[1].lower() if len(parts) > 1 else "list"
        handler = {
            "list": self._snapshot_list, "ls": self._snapshot_list, "create": self._snapshot_create,
            "restore": self._snapshot_restore, "rewind": self._snapshot_restore,
            "prune": self._snapshot_prune}.get(subcmd)
        if handler is None:
            return _pr(f"  {_t('snapshot.unknown_subcommand', subcommand=subcmd)}",
                       f"  {_t('snapshot.usage')}")
        handler(parts)

    def _snapshot_list(self, parts) -> None:
        from hermes_cli.backup import list_quick_snapshots
        snaps = list_quick_snapshots()
        if not snaps:
            return _pr(f"  {_t('snapshot.none_yet')}", f"  {_t('snapshot.create_hint')}")
        print(f"  {_t('snapshot.list_header', dir=display_hermes_home())}\n")
        # Column labels are translated values re-padded here (never padded in the catalog).
        _pr(f"  {_t('snapshot.col_num'):>3}  {_t('snapshot.col_id'):<35} {_t('snapshot.col_files'):>5} "
            f"{_t('snapshot.col_size'):>10} {_t('snapshot.col_label')}",
            f"  {'─'*3}  {'─'*35} {'─'*5} {'─'*10} {'─'*20}")
        for i, s in enumerate(snaps, 1):
            size = s.get("total_size", 0)
            size_str = (f"{size} B" if size < 1024 else f"{size / 1024:.0f} KB"
                        if size < 1024 * 1024 else f"{size / 1024 / 1024:.1f} MB")
            label = s.get("label") or ""
            print(f"  {i:3}  {s['id']:<35} {s.get('file_count', 0):>5} {size_str:>10} {label}")

    def _snapshot_create(self, parts) -> None:
        from hermes_cli.backup import create_quick_snapshot
        snap_id = create_quick_snapshot(label=" ".join(parts[2:]) if len(parts) > 2 else None)
        print(f"  {_t('snapshot.created', snapshot_id=snap_id)}" if snap_id
              else f"  {_t('snapshot.nothing_to_snapshot')}")

    def _snapshot_restore(self, parts) -> None:
        from hermes_cli.backup import list_quick_snapshots, restore_quick_snapshot
        if len(parts) < 3:
            print(f"  {_t('snapshot.usage_restore')}")
            snaps = list_quick_snapshots(limit=1)
            if snaps:
                print(f"  {_t('snapshot.most_recent', snapshot_id=snaps[0]['id'])}")
            return
        snap_id = parts[2]
        try:
            idx = int(snap_id)  # restore by number (1-indexed)
        except ValueError:
            idx = None
        if idx is not None:
            snaps = list_quick_snapshots()
            if not 1 <= idx <= len(snaps):
                return print(f"  {_t('snapshot.invalid_number', max=len(snaps))}")
            snap_id = snaps[idx - 1]["id"]
        # Close our SessionDB first so the restore doesn't contend with this process's live connection.
        local_session_db = getattr(self, "_session_db", None)
        if local_session_db is not None:
            with suppress(Exception):
                local_session_db.close()
                self._session_db = None
        if restore_quick_snapshot(snap_id):
            _pr(f"  {_t('snapshot.restored', snapshot_id=snap_id)}",
                f"  {_t('snapshot.restart_recommended')}")
        else:
            print(f"  {_t('snapshot.not_found', snapshot_id=snap_id)}")

    def _snapshot_prune(self, parts) -> None:
        from hermes_cli.backup import prune_quick_snapshots
        keep = 20
        if len(parts) > 2:
            # isdecimal() also rejects "-1": a negative keep would slice away the
            # newest snapshots instead of the oldest.
            if not parts[2].isdecimal():
                return print(f"  {_t('snapshot.usage_prune')}")
            keep = int(parts[2])
        deleted = prune_quick_snapshots(keep=keep)
        print(f"  {_t('snapshot.pruned', deleted=deleted, keep=keep)}")

    # ---- /export, /import -----------------------------------------------------------------
    def _handle_export_command(self, command: str):
        """Handle /export [profile] [-o path] — export a profile to a shareable .tar.gz archive."""
        from hermes_cli.profiles import export_profile, get_active_profile_name, get_profile_export_path
        parts, output, ok = _take_flag(command.split()[1:], "-o")
        if not ok:
            return print(f"  {_t('export.usage')}")
        name = parts[0] if parts else (get_active_profile_name() or "default")
        try:
            result = export_profile(name, output or str(get_profile_export_path(name)))
            _pr(f"  {_t('export.exported', name=name, path=result)}", f"  {_t('export.share_hint')}")
        except (ValueError, FileNotFoundError, OSError) as e:
            print(f"  {_t('shared.error', error=e)}")

    def _handle_import_command(self, command: str):
        """Handle /import <archive.tar.gz> [--name <name>] — import a shared profile archive as a
        new profile."""
        from hermes_cli.profiles import check_alias_collision, create_wrapper_script, import_profile
        parts, name, ok = _take_flag(command.split()[1:], "--name")
        if not ok or not parts:
            return print(f"  {_t('import.usage')}")
        try:
            profile_dir = import_profile(" ".join(parts), name=name)  # paths may contain spaces
        except (ValueError, FileExistsError, FileNotFoundError) as e:
            return print(f"  {_t('shared.error', error=e)}")
        imported = profile_dir.name
        print(f"  {_t('import.imported', name=imported, path=profile_dir)}")
        with suppress(Exception):
            if not check_alias_collision(imported):
                wrapper_path = create_wrapper_script(imported)
                if wrapper_path:
                    print(f"  {_t('import.wrapper_created', path=wrapper_path)}")
        print(f"  {_t('import.use_it', name=imported)}")

    # ---- /stop, /agents -------------------------------------------------------------------
    def _handle_stop_command(self):
        """Handle /stop — kill all running background processes and background (async) delegations.
        Separate from interrupt (stop the current turn), as in Codex.

        See #14602.
        """
        from tools.process_registry import process_registry
        running = [p for p in process_registry.list_sessions() if p.get("status") == "running"]
        # Background subagents live in their own registry, not the process registry.
        n_async = _probe("tools.async_delegation", "active_count", 0)
        if not running and not n_async:
            return print(f"  {_t('stop.none_running')}")
        if running:
            print(f"  {_t('stop.stopping', count=len(running))}")
            print(f"  {_t('stop.stopped', count=process_registry.kill_all(source='cli.stop'))}")
        if n_async:
            from tools.async_delegation import interrupt_all
            print(f"  {_t('stop.interrupted_delegations', count=interrupt_all(reason='/stop'))}")

    def _handle_agents_command(self):
        """Handle /agents — show background processes and agent status."""
        from tools.process_registry import format_uptime_short, process_registry
        processes = process_registry.list_sessions()
        running = [p for p in processes if p.get("status") == "running"]
        finished = [p for p in processes if p.get("status") != "running"]
        _cp(f"  {_t('agents.running_processes', count=len(running))}")
        for p in running:
            up = format_uptime_short(p.get("uptime_seconds", 0))
            _cp(f"    {p.get('session_id', '?')} · {up} · {p.get('command', '')[:80]}")
        if finished:
            _cp(f"  {_t('agents.recently_finished', count=len(finished))}")
        # Background (async) delegations — delegate_task(background=true)
        delegations = _probe("tools.async_delegation", "list_async_delegations", [])
        if delegations:
            running_d = [d for d in delegations if d.get("status") in ("running", "stalling")]
            _cp(f"  {_t('agents.background_delegations', count=len(running_d))}")
            for d in delegations:
                status = d.get("status", "?")
                line = f"    {d.get('delegation_id', '?')} · {status} · {(d.get('goal') or '')[:60]}"
                # Live-status detail for in-flight delegations.
                # See #51690.
                if status == "stalling":
                    quiet = d.get("stalled_after_quiet_seconds")
                    if quiet is not None:
                        line += _t("agents.no_progress", seconds=f"{quiet:.0f}")
                elif status == "running":
                    quiet = d.get("seconds_since_progress")
                    if quiet is not None and quiet >= 60:
                        line += _t("agents.quiet", seconds=f"{quiet:.0f}")
                _cp(line)
                for i, child in enumerate(d.get("children_activity") or []):
                    if not isinstance(child, dict):
                        continue
                    tool = child.get("current_tool")
                    doing = _t("agents.in_tool", tool=tool) if tool else _t("agents.between_turns")
                    part = "      " + _t("agents.child_line", index=i + 1,
                                         api_calls=child.get("api_calls", "?"), doing=doing)
                    idle = child.get("seconds_since_activity")
                    if idle is not None:
                        part += _t("agents.last_activity", seconds=f"{idle:.0f}")
                    _cp(part)
        agent_running = getattr(self, "_agent_running", False)
        _cp(f"  {_t('agents.agent_running') if agent_running else _t('agents.agent_idle')}")

    # ---- /journey, /paste, /copy, /image --------------------------------------------------
    def _handle_journey_command(self, cmd_original: str) -> None:
        """Handle /journey — the learning timeline (see `hermes journey`). Read-only views render
        Rich color that patch_stdout would swallow, so capture with forced ANSI and re-emit via
        ``_cprint``; ``delete``/``edit`` are interactive and keep real stdio."""
        from hermes_cli.journey import register_cli
        parser = argparse.ArgumentParser(prog="/journey", add_help=False)
        register_cli(parser)
        try:
            args = parser.parse_args(shlex.split(cmd_original)[1:])
        except SystemExit:
            return
        try:
            if getattr(args, "journey_action", None) in ("delete", "edit"):
                args.func(args)
                return
            args.force_color = True
            buf = io.StringIO()
            with redirect_stdout(buf):
                args.func(args)
            _cp(buf.getvalue().rstrip("\n"))
        except Exception as exc:
            _cp(f"  {_t('journey.failed', error=exc)}")

    def _handle_paste_command(self):
        """Handle /paste — explicitly check clipboard for an image.

        This is the reliable fallback for terminals where BracketedPaste
        doesn't fire for image-only clipboard content (e.g., VSCode terminal,
        Windows Terminal with WSL2).
        """
        from hermes_cli.clipboard import has_clipboard_image
        if not has_clipboard_image():
            _cp(_dim_line(_t("paste.no_image")))
        elif self._try_attach_clipboard_image():
            _cp(f"  {_t('paste.attached', index=len(self._attached_images))}")
        else:
            _cp(_dim_line(_t("paste.extract_failed")))

    def _handle_copy_command(self, cmd_original: str) -> None:
        """Handle /copy [number] — copy assistant output to clipboard."""
        from cli import _assistant_copy_text
        arg = _command_arg(cmd_original)
        assistant = [m for m in self.conversation_history if m.get("role") == "assistant"]
        if not assistant:
            return _cp(f"  {_t('copy.nothing_yet')}")
        if arg:
            try:
                idx = int(arg) - 1
            except ValueError:
                return _cp(f"  {_t('copy.usage')}")
            if idx < 0 or idx >= len(assistant):
                return _cp(f"  {_t('copy.invalid_number', max=len(assistant))}")
        else:  # latest response that has copyable text
            idx = next((i for i in range(len(assistant) - 1, -1, -1)
                        if _assistant_copy_text(assistant[i].get("content"))), -1)
            if idx < 0:
                return _cp(f"  {_t('copy.nothing_in_responses')}")
        text = _assistant_copy_text(assistant[idx].get("content"))
        if not text:
            return _cp(f"  {_t('copy.nothing_in_response')}")
        try:
            from hermes_cli.clipboard import is_remote_shell_session, write_clipboard_text
            # Over SSH native tools write the REMOTE clipboard; OSC 52 reaches the user's terminal.
            # Locally, OSC 52 is the fallback when native tools are unavailable/fail (SSH/tmux).
            if is_remote_shell_session() or not write_clipboard_text(text):
                # Fixes #31528.
                self._write_osc52_clipboard(text)
                _cp(f"  {_t('copy.copied_osc52', index=idx + 1)}")
            else:
                _cp(f"  {_t('copy.copied', index=idx + 1)}")
        except Exception as e:
            _cp(f"  {_t('copy.failed', error=e)}")

    def _handle_image_command(self, cmd_original: str):
        """Handle /image <path> — attach a local image file for the next prompt."""
        from cli import _DIM, _IMAGE_EXTENSIONS, _RST, _cprint, _resolve_attachment_path, _split_path_input
        raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "")
        if not raw_args:
            hint = "/path/to/image.png"
            _cprint(f"  {_DIM}{_t('image.usage', example=hint)}{_RST}")
            return

        path_token, _remainder = _split_path_input(raw_args)
        image_path = _resolve_attachment_path(path_token)
        if image_path is None:
            return _cp(_dim_line(_t("image.not_found", path=path_token)))
        if image_path.suffix.lower() not in _IMAGE_EXTENSIONS:
            return _cp(_dim_line(_t("image.unsupported", name=image_path.name)))
        self._attached_images.append(image_path)
        _cp(f"  {_t('image.attached', name=image_path.name)}")
        if _remainder:
            _cprint(f"  {_DIM}{_t('image.now_type_prompt', text=_remainder)}{_RST}")

    # ---- /tools, /profile -----------------------------------------------------------------
    def _handle_tools_command(self, cmd: str):
        """Handle /tools [list|disable|enable]. Bare shows the tool list; ``list`` shows per-toolset
        status; disable/enable save to config and reset the session so the new tool set takes
        effect cleanly (no prompt-cache breakage mid-conversation)."""
        parts = _shlex_args(cmd)
        subcommand = parts[0] if parts else ""
        if subcommand not in {"list", "disable", "enable"}:
            return self.show_tools()
        if subcommand == "list":
            return self._run_tools_config(tools_action="list", platform="cli")
        names = parts[1:]
        if not names:
            return _pr(_t("tools.usage", subcommand=subcommand),
                       f"  {_t('tools.example_toolset', subcommand=subcommand)}",
                       f"  {_t('tools.example_mcp', subcommand=subcommand)}")
        # Typing the command is consent. Do NOT use input() — it hangs in prompt_toolkit's loop.
        _cp(_accent(_t("tools.disabling" if subcommand == "disable" else "tools.enabling",
                       names=", ".join(names))))
        self._run_tools_config(tools_action=subcommand, names=names, platform="cli")
        from hermes_cli.tools_config import _get_platform_tools
        from hermes_cli.config import load_config
        self.enabled_toolsets = _get_platform_tools(load_config(), "cli")
        self.new_session()
        _cp(_dim(_t("tools.session_reset")))

    def _run_tools_config(self, **ns) -> None:
        """Run ``tools_disable_enable_command``. Inside the interactive TUI its ANSI print() output
        is captured (isatty=True so colors still render) and re-emitted through _cprint so
        patch_stdout's StdoutProxy doesn't garble the escapes; standalone/tests call straight through."""
        from argparse import Namespace
        from hermes_cli.tools_config import tools_disable_enable_command
        if getattr(self, "_app", None) is None:
            return tools_disable_enable_command(Namespace(**ns))
        buf = _TTYBuf()
        with redirect_stdout(buf):
            tools_disable_enable_command(Namespace(**ns))
        _cp(*buf.getvalue().splitlines())

    def _handle_profile_command(self):
        """Display active profile name and home directory."""
        from hermes_cli.slash_exec import CommandContext, execute_command
        reply = execute_command("profile", CommandContext(surface="cli"))
        _say_block(f"  {_t('profile.profile', profile=reply.data['profile'])}",
                   f"  {_t('profile.home', home=reply.data['home'])}")

    # ---- /handoff -------------------------------------------------------------------------

    _HANDOFF_PENDING_TIMEOUT = 60.0
    _HANDOFF_RUNNING_TIMEOUT = 900.0  # full synthetic agent turn + delivery
    _HANDOFF_HEARTBEAT_EVERY = 30.0

    @staticmethod
    def _handoff_keep(*lines: str) -> bool:
        """Print ``lines`` and keep the CLI session (the True verdict of /handoff)."""
        _cp(*lines)
        return True

    def _handle_handoff_command(self, cmd_original: str) -> bool:
        """Handle ``/handoff <platform>`` — transfer this CLI session to a gateway platform.

        Validate target → prepare session row → mark pending → block-poll (see ``_handoff_wait``).
        Returns False only on ``completed`` (caller exits like /quit); True keeps the session."""
        platform_name = _command_arg(cmd_original).lower()
        if not platform_name:
            return self._handoff_keep(
                f"  {_t('handoff.usage')}", f"  {_t('handoff.usage_detail_1')}",
                f"  {_t('handoff.usage_detail_2')}")
        home = self._handoff_validate_target(platform_name)
        if home is None:
            return True
        session_title = self._handoff_prepare_session()
        if session_title is None:
            return True
        if not self._session_db.request_handoff(self.session_id, platform_name):
            return self._handoff_keep(f"  {_t('handoff.already_in_flight')}")
        _cp(f"  {_t('handoff.queued', title=session_title, platform=platform_name, home=home.name)}",
            f"  {_t('handoff.waiting')}")
        return self._handoff_wait(platform_name, session_title)

    def _handoff_validate_target(self, platform_name: str):
        """Resolve the destination home channel via the live gateway config; None (after printing
        the reason) when the platform is unknown, disabled, or has no home channel."""
        try:
            from gateway.config import load_gateway_config, Platform
        except Exception as exc:  # pragma: no cover — gateway pkg always shipped
            return _cp(f"  {_t('handoff.config_load_failed', error=exc)}")
        try:
            platform = Platform(platform_name)
        except (ValueError, KeyError):
            return _cp(f"  {_t('handoff.unknown_platform', platform=platform_name)}")
        try:
            gw_config = load_gateway_config()
        except Exception as exc:
            return _cp(f"  {_t('handoff.config_load_failed', error=exc)}")
        pcfg = gw_config.platforms.get(platform)
        if not pcfg or not pcfg.enabled:
            # Relay aliasing: a relay-fronted gateway has only a RELAY block yet /handoff discord
            # is deliverable. UX pre-check only — the gateway watcher re-checks before dispatch.
            relay_fronts = False
            with suppress(Exception):
                from gateway.relay import relay_platform_identities
                relay_cfg = gw_config.platforms.get(Platform.RELAY)
                if relay_cfg and relay_cfg.enabled:
                    relay_fronts = platform_name in {p for p, _ in relay_platform_identities()}
            if not relay_fronts:
                return _cp(f"  {_t('handoff.platform_not_enabled', platform=platform_name)}")
        home = gw_config.get_home_channel(platform)
        if not home or not home.chat_id:
            return _cp(f"  {_t('handoff.no_home_channel', platform=platform_name)}",
                       f"  {_t('handoff.set_home_hint')}")
        return home

    def _handoff_prepare_session(self):
        """Refuse mid-turn, make sure a SessionDB handle + session row exist, and return the
        display title (None after printing why the handoff cannot start)."""
        # An in-flight agent run would race the gateway's switch_session and the synthetic turn.
        if getattr(self, "_agent_running", False):
            return _cp(f"  {_t('shared.agent_busy', command='/handoff')}")
        if not self._session_db:
            with suppress(Exception):
                from hermes_state_registry import acquire
                self._session_db = acquire()
        if not self._session_db:
            return _cp(_db_unavailable_line())
        # Ensure the session row exists (an empty session has flushed nothing yet): the gateway
        # needs a row to switch_session onto; set_session_title's INSERT OR IGNORE creates it.
        try:
            if not self._session_db.get_session(self.session_id):
                self._session_db.set_session_title(self.session_id, f"handoff-{self.session_id[:8]}")
        except Exception as exc:
            return _cp(f"  {_t('handoff.session_row_failed', error=exc)}")
        session_title = ""
        with suppress(Exception):
            session_title = (self._session_db.get_session(self.session_id) or {}).get("title") or ""
        return session_title or self.session_id[:8]

    def _handoff_wait(self, platform_name: str, session_title: str) -> bool:
        """Two-phase 0.5s poll. PENDING (unclaimed): 60s, then CAS-fail the row so the user can
        retry (a claim racing this instant wins). RUNNING (claimed): the gateway replays the
        transcript via a synthetic turn (routinely >60s) — wait 15 min with heartbeats and on
        timeout do NOT touch the row; failing it here was the split-brain bug."""
        pending_deadline = time.time() + self._HANDOFF_PENDING_TIMEOUT
        running_deadline = None
        next_heartbeat = None
        last_state = "pending"
        while True:
            try:
                state_row = self._session_db.get_handoff_state(self.session_id)
            except Exception:
                state_row = None
            current = (state_row or {}).get("state") or "pending"
            if current != last_state:
                if current == "running":
                    _cp(f"  {_t('handoff.picked_up')}")
                    running_deadline = time.time() + self._HANDOFF_RUNNING_TIMEOUT
                    next_heartbeat = time.time() + self._HANDOFF_HEARTBEAT_EVERY
                last_state = current
            if current == "completed":
                _cp("", f"  {_t('handoff.complete', platform=platform_name)}",
                    f"  {_t('handoff.resume_hint', title=session_title)}", "")
                # _run_cleanup must NOT finalize the row on exit: the gateway owns it now, and an
                # end_reason set under it would drop the handoff leg from session history/search.
                # See #88234.
                from cli import _handed_off_session_ids
                _handed_off_session_ids.add(self.session_id)
                self._should_exit = True  # same exit semantics as /quit
                return False
            if current == "failed":
                err = (state_row or {}).get("error") or _t("handoff.unknown_error")
                return self._handoff_keep(
                    f"  {_t('handoff.failed', error=err)}", f"  {_t('handoff.session_intact_retry')}")
            now = time.time()
            if current == "pending":
                if now >= pending_deadline:
                    break
            else:  # running
                if next_heartbeat is not None and now >= next_heartbeat:
                    _cp(f"  {_t('handoff.still_transferring')}")
                    next_heartbeat = now + self._HANDOFF_HEARTBEAT_EVERY
                if running_deadline is not None and now >= running_deadline:
                    # Do NOT fail the row: the gateway owns it (split-brain bug otherwise).
                    return self._handoff_keep(
                        f"  {_t('handoff.taking_long')}",
                        f"  {_t('handoff.check_platform', platform=platform_name)}",
                        f"  {_t('handoff.no_longer_waiting')}", f"  {_t('handoff.retry_when_settled')}")
            time.sleep(0.5)
        try:  # pending timed out: CAS-clear so the user can retry
            self._session_db.fail_handoff(
                self.session_id, "timed out waiting for gateway", only_states=("pending",))
        except TypeError:
            # Older SessionDB without only_states (mixed installs): legacy unconditional fail.
            with suppress(Exception):
                self._session_db.fail_handoff(self.session_id, "timed out waiting for gateway")
        except Exception:
            pass
        return self._handoff_keep(f"  {_t('handoff.timed_out')}", f"  {_t('handoff.session_intact')}")

    # ---- /resume, /sessions, /branch ------------------------------------------------------
    def _handle_resume_command(self, cmd_original: str) -> None:
        """Handle /resume <session_id_or_title> — switch to a previous session mid-conversation."""
        if getattr(self, "_agent_running", False):
            return _cp(f"  {_t('shared.agent_busy', command='/resume')}")
        from cli import _sync_process_session_id
        target = _command_arg(cmd_original)
        # Users copy the help text's placeholder brackets/quotes verbatim (``/resume <abc123>``).
        if len(target) >= 2 and target[0] + target[-1] in {"<>", "[]", '""', "''"}:
            target = target[1:-1].strip()
        if not target:
            _cp(f"  {_t('resume.usage')}")
            if self._show_recent_sessions(reason="resume"):
                # Arm a one-shot bare-number selection; must be the same list the table showed
                # and the numbered branch resolves (all use _list_recent_sessions(limit=10)).
                # Arm a one-shot pending-resume selection so the user can type just the number (`3`) on the
                # next line instead of having to retype `/resume 3`. The list here must match the one shown
                # by _show_recent_sessions and used for index resolution below — all three go through
                # _list_recent_sessions(limit=10). See #34584.
                self._pending_resume_sessions = self._list_recent_sessions(limit=10)
                return
            return _cp(f"  {_t('resume.tip_find_sessions')}")
        # Any explicit /resume <target> supersedes a previously-armed bare numbered prompt.
        self._pending_resume_sessions = None
        if not self._session_db:
            return _cp(_db_unavailable_line())
        resolved = self._resolve_resume_target(target)
        if resolved is None:
            return
        target_id, session_meta = resolved
        if target_id == self.session_id:
            return _cp(f"  {_t('resume.already_on')}")
        old_session_id = self.session_id
        _end_current_session(self, "resumed_other")
        self.session_id, self._resumed, self._pending_title = target_id, True, None
        _sync_process_session_id(target_id)
        # One lineage SELECT, two projections: model_history is alternation-repaired for live
        # replay (heals a durable user;user once); display_history is verbatim (as startup --resume).
        model_history, display_history = self._session_db.get_resume_conversations(target_id)
        self.conversation_history = _without_session_meta(model_history)
        self._resume_display_history = _without_session_meta(display_history)
        with suppress(Exception):  # re-open the target session so it's not marked as ended
            self._session_db.reopen_session(target_id)
        _sync_agent_to_session(self, target_id, parent_session_id=old_session_id, reason="resume")
        title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else ""
        from agent.context_compressor import is_user_originated_turn
        # Count only user-originated turns: legacy compaction handoffs are durable role=user rows
        # without display_kind.
        msg_count = len([m for m in self._resume_display_history if is_user_originated_turn(m)])
        if self.conversation_history:
            _cp("  " + _tn("resume.resumed", msg_count, session_id=target_id, title=title_part,
                           total=len(self.conversation_history)))
            self._display_resumed_history()
        else:
            _cp(f"  {_t('resume.resumed_empty', session_id=target_id, title=title_part)}")
        # Same contract as startup --resume: retarget the tool cwd, restore the persisted YOLO
        # bypass (approval session key changed) and the model/provider (else config default).
        # Retarget the process + tool cwd to where the session was started, so a mid-chat /resume (and
        # /sessions <id>, which delegates here) lands in the same directory as a startup `hermes
        # -c`/`--resume`. The startup resume paths already call this; without it, the terminal/code-exec
        # tools and relative-path resolution keep operating in the wrong repo. Idempotent and a no-op when
        # the session recorded no cwd. See #38562.
        self._restore_session_cwd(session_meta)
        self._restore_session_yolo(session_meta)
        self._restore_session_model(session_meta)

    def _resolve_resume_target(self, target: str):
        """``(session_id, meta)`` for a numbered selection, title, or id; None after printing why
        it could not be resolved. An empty compression-chain head redirects to the descendant
        that actually holds the transcript."""
        if target.isdigit():
            sessions = self._list_recent_sessions(limit=10)
            index = int(target)
            if index < 1 or index > len(sessions):
                return _cp(*_lines(_gt("resume.out_of_range", index=index)))
            target_id = sessions[index - 1]["id"]
        else:
            from hermes_cli.main import _resolve_session_by_name_or_id
            target_id = _resolve_session_by_name_or_id(target) or target
        session_meta = self._session_db.get_session(target_id)
        if not session_meta:
            return _cp(f"  {_t('resume.not_found', target=target)}", f"  {_t('resume.not_found_hint')}")
        try:
            # If the target is the empty head of a compression chain, redirect to the descendant that
            # actually holds the transcript. See #15000.
            resolved_id = self._session_db.resolve_resume_session_id(target_id)
        except Exception:
            resolved_id = target_id
        if resolved_id and resolved_id != target_id:
            _cp(f"  {_t('resume.compressed_redirect', session_id=target_id, resolved_id=resolved_id)}")
            target_id = resolved_id
            session_meta = self._session_db.get_session(target_id) or session_meta
        return target_id, session_meta

    def _handle_sessions_command(self, cmd_original: str) -> None:
        """Handle /sessions [list|<id_or_title>] — bare/``list`` prints the recent-sessions table;
        an explicit target delegates to /resume so both spellings behave identically."""
        arg = _command_arg(cmd_original)
        if arg and arg.lower() not in {"list", "ls", "browse"}:
            self._handle_resume_command(f"/resume {arg}")
        elif not self._session_db:
            _cp(_db_unavailable_line())
        elif not self._show_recent_sessions(reason="sessions"):
            _cp(f"  {_t('sessions.none_yet')}")

    def _handle_branch_command(self, cmd_original: str) -> None:
        """Handle /branch [name] — fork the current session into a new independent copy of the
        full history so a different approach can be explored without losing the original."""
        # An in-flight agent run would flush through the rotating session identity: the branch
        # ends the parent row and repoints agent.session_id (_sync_agent_to_session), so the
        # turn's remaining messages land on the branch. Refuse mid-turn like /handoff does.
        if getattr(self, "_agent_running", False):
            return _cp(f"  {_t('shared.agent_busy', command='/branch')}")
        from cli import _sync_process_session_id
        if not self.conversation_history:
            return _cp(f"  {_gt('branch.no_conversation')}")
        if not self._session_db:
            return _cp(_db_unavailable_line())
        # CLI has no threads: always in place; strip the gateway's ``--here`` so it is never a title.
        from gateway.slash_commands_branch_thread import parse_branch_args
        _, branch_name = parse_branch_args(_command_arg(cmd_original))
        now = datetime.now()
        new_session_id = mint_session_id(now)
        branch_title = branch_name or self._session_db.get_next_title_in_lineage(
            self._session_db.get_session_title(self.session_id) or "branch")
        parent_session_id = self.session_id
        # Create the child BEFORE ending the parent: a failed create_session must leave the session the
        # user is still on open, not ended with end_reason="branched" and no branch (#11030).
        # The stable ``_branched_from`` marker keeps the branch visible in /resume + /sessions
        # even after the parent is re-ended with a different end_reason.
        # The child sends the parent's exact system prompt: a row without one makes the branch's first
        # turn rebuild (re-probing the workspace), so the warm cache the copied transcript buys is
        # lost at byte 0 whenever the repo moved since the parent's session start.
        parent_prompt = getattr(self.agent, "_cached_system_prompt", None)
        if not isinstance(parent_prompt, str) or not parent_prompt:
            with suppress(Exception):
                parent_prompt = (self._session_db.get_session(parent_session_id) or {}).get("system_prompt")
        try:
            self._session_db.create_session(
                session_id=new_session_id, source=os.environ.get("HERMES_SESSION_SOURCE", "cli"),
                model=self.model, parent_session_id=parent_session_id, system_prompt=parent_prompt or None,
                model_config={"max_iterations": self.max_turns, "reasoning_config": self.reasoning_config,
                              "_branched_from": parent_session_id})
        except Exception as e:
            return _cp(f"  {_gt('branch.create_failed', error=e)}")
        _end_current_session(self, "branched")
        # Best-effort chunked copy (a failed copy still yields a usable branch); the api_content
        # sidecar lets the branch's first turn replay the parent's exact wire bytes (warm cache).
        with suppress(Exception):
            self._session_db.append_messages_batch(new_session_id, [
                {"role": msg.get("role", "user"), "tool_name": msg.get("tool_name") or msg.get("name"),
                 "api_content": extract_api_content_sidecar(msg),
                 **{k: msg.get(k) for k in _BRANCH_COPY_KEYS}, **message_identity(msg, with_tool_uids=True)}
                for msg in self.conversation_history], chunk_rows=500)
        with suppress(Exception):
            self._session_db.set_session_title(new_session_id, branch_title)
        # Switch to the new session
        self._transfer_session_yolo(self.session_id, new_session_id)
        self.session_id, self.session_start, self._pending_title = new_session_id, now, None
        self._resumed = True  # Prevents auto-title generation
        _sync_process_session_id(new_session_id)
        if self.agent:
            self.agent.session_start = now
        _sync_agent_to_session(self, new_session_id, parent_session_id=parent_session_id, reason="branch")
        msg_count = len([m for m in self.conversation_history if m.get("role") == "user"])
        _cp("  " + _tn("branch.branched", msg_count, title=branch_title),
            f"  {_t('branch.original_session', session_id=parent_session_id)}",
            f"  {_t('branch.branch_session', session_id=new_session_id)}")

    # ---- /worktree ------------------------------------------------------------------------
    def _handle_worktree_command(self, cmd_original: str) -> None:
        """Handle /worktree [new [name]|list|prune [--dry-run]] — isolated git worktrees.
        ``new`` moves this session into the tree (as ``hermes -w``: kept on exit only with
        unpushed commits); ``prune`` never deletes tracked changes, unique commits, or in-use trees."""
        import cli as _cli
        parts = cmd_original.split(None, 2)
        sub = parts[1].lower() if len(parts) > 1 else ""
        rest = parts[2].strip() if len(parts) > 2 else ""
        repo_root = _cli._git_repo_root()
        if not sub or sub in {"status", "show"}:
            active = _cli._active_worktree
            if active:
                _pr(f"  {_t('worktree.active', path=active['path'])}",
                    f"  {_t('worktree.branch', branch=active['branch'])}")
            else:
                print(f"  {_t('worktree.none_active')}")
            if repo_root:
                _pr(f"  {_t('worktree.hint_new')}", f"  {_t('worktree.hint_prune')}")
            else:
                print(f"  {_t('worktree.not_in_repo_note')}")
            return
        handler = _WORKTREE_SUBCOMMANDS.get(sub)
        if handler is None:
            return _pr(f"  {_t('worktree.unknown_subcommand', subcommand=sub)}", f"  {_t('worktree.usage')}")
        if not repo_root:
            print(f"  {_t('worktree.new_requires_repo')}" if handler == "_worktree_new"
                  else f"  {_t('worktree.not_in_repo')}")
            return
        getattr(self, handler)(repo_root, rest)

    def _worktree_prune(self, repo_root: str, rest: str) -> None:
        import cli as _cli
        from hermes_cli import worktree_gc
        rest = rest.lower()
        dry_run = "--dry-run" in rest or "-n" in rest.split()
        active = _cli._active_worktree
        tree_records = worktree_gc.audit_worktrees(repo_root, with_sizes=False)
        if active:
            # Never reap the tree this session is sitting in, even if judged clean+merged.
            active_path = str(active.get("path") or "")
            tree_records = [record for record in tree_records if record.path != active_path]
        actions = worktree_gc.reclaim_worktrees(repo_root, dry_run=dry_run, records=tree_records)
        actions += worktree_gc.reclaim_branches(repo_root, dry_run=dry_run)
        if actions:
            _pr(*(f"  {line}" for line in actions),
                "  " + _t("worktree.actions_planned" if dry_run else "worktree.actions_done", count=len(actions)))
        else:
            print(f"  {_t('worktree.nothing_to_reclaim')}")
        kept = [r for r in tree_records
                if r.verdict == "keep" and "kanban" not in r.reason and "in use" not in r.reason]
        if kept:
            _pr(f"  {_t('worktree.preserved', count=len(kept))}",
                *(f"    {record.name}: {record.reason}" for record in kept))

    def _worktree_list(self, repo_root: str, rest: str) -> None:
        try:
            result = subprocess.run(
                ["git", "worktree", "list"], capture_output=True, text=True, encoding="utf-8",
                errors="replace", timeout=10, cwd=repo_root)
            out = result.stdout.strip() if result.returncode == 0 else ""
        except Exception:
            out = ""
        _pr(*(f"  {line}" for line in out.splitlines()) if out else (f"  {_t('worktree.list_failed')}",))

    def _worktree_new(self, repo_root: str, rest: str) -> None:
        import cli as _cli
        from hermes_cli.config import load_config
        try:
            sync_base = bool(load_config().get("worktree_sync", True))
        except Exception:
            sync_base = True
        wt_info = _cli._setup_worktree(repo_root=repo_root, sync_base=sync_base, name=rest or None)
        if not wt_info:
            return  # _setup_worktree already printed the failure
        # Retarget the session's terminal/file tools at the new tree (as `hermes -w` does).
        try:
            os.chdir(wt_info["path"])
        except OSError as e:
            print(f"  {_t('worktree.enter_failed', error=e)}")
        os.environ["TERMINAL_CWD"] = wt_info["path"]
        # Same keep-if-unpushed cleanup as `hermes -w`. Only one tree is "active" per process;
        # an earlier one keeps its own atexit registration (explicit info arg).
        _cli._active_worktree = wt_info
        atexit.register(_cli._cleanup_worktree, wt_info)
        _pr(f"  {_t('worktree.ready', path=wt_info['path'])}",
            f"  {_t('worktree.branch', branch=wt_info['branch'])}", f"  {_t('worktree.tools_in_worktree')}")

    # ---- /personality, /pet, /hatch -------------------------------------------------------
    def _handle_personality_command(self, cmd: str):
        """Handle /personality [name] — list or set a predefined personality. All resolution and
        persistence goes through hermes_cli.personality, the single owner of personality state."""
        from hermes_cli.personality import (
            describe_personality, normalize_personality_name, persist_personality, prompt_text,
            resolve_personality)
        personality_name = _command_arg(cmd)
        if not personality_name:
            try:
                from hermes_cli.config import read_raw_config
                current = normalize_personality_name(
                    (read_raw_config().get("display") or {}).get("personality", ""))
            except Exception:
                current = ""
            _pr("", "+" + "-" * 50 + "+", "|" + _t("personality.title").center(50) + "|",
                "+" + "-" * 50 + "+", "",
                f" {' *' if not current else '  '}{'none':<12} - {_t('personality.none_option')}")
            for name, prompt in self.personalities.items():
                marker = " *" if name == current else "  "
                print(f" {marker}{name:<12} - {describe_personality(prompt)}")
            return _pr("", f"  {_t('personality.usage')}", "")
        try:
            name, personality_prompt = resolve_personality(personality_name, getattr(self, "config", None))
        except ValueError:
            print(_t("personality.unknown", name=personality_name.lower()))
            return print(f"  {_t('personality.available', available=', '.join(self.personalities.keys()))}")
        saved = persist_personality(name)
        scope = _t("personality.scope_saved") if saved else _t("personality.scope_session")
        face = "(^_^)b" if saved else "(^_^)"
        if not name:
            # Neutral reset — fall back to the user-owned manual prompt.
            try:
                from hermes_cli.config import cfg_get, read_raw_config
                self.system_prompt = prompt_text(
                    cfg_get(read_raw_config(), "agent", "system_prompt", default=""))
            except Exception:
                self.system_prompt = ""
            _retire_agent(self)  # Force re-init
            _pr(_t("personality.cleared", face=face, scope=scope), f"  {_t('personality.cleared_note')}")
        else:
            self.system_prompt = personality_prompt
            _retire_agent(self)  # Force re-init
            _pr(_t("personality.set_to", face=face, name=name, scope=scope),
                f"  \"{_ellipsize(personality_prompt, 60)}\"")

    def _handle_pet_command(self, cmd: str):
        """Handle /pet [toggle|list|scale <n>|off|<slug>] — the petdex mascot. Writes
        ``display.pet.*`` to config; pet surfaces pick it up on their next poll."""
        from agent.pet import store
        from agent.pet.manifest import ManifestError
        from hermes_cli.pets import (
            _set_active, _set_enabled, print_pet_gallery, set_pet_scale, toggle_pet_display)
        arg = _command_arg(cmd)
        low = arg.lower()
        if not arg or low == "toggle":
            enabled, name, err = toggle_pet_display()
            print(_t("pet.error", error=err) if err else _t("pet.is_out", name=name) if enabled
                  else _t("pet.put_away_named", name=name) if name else _t("pet.put_away"))
        elif low in ("list", "gallery", "browse", "all"):
            print_pet_gallery()
        elif low == "scale" or low.startswith("scale "):
            value = arg[len("scale"):].strip()
            if not value:
                return print(_t("pet.usage_scale"))
            scale, err = set_pet_scale(value)
            print(_t("pet.error", error=err) if err else _t("pet.scale_set", scale=f"{scale:g}"))
        elif low == "off":
            _set_enabled(False)
            print(_t("pet.put_away"))
        else:
            print(_t("pet.fetching", name=arg))
            try:
                pet = store.install_pet(arg)
            except (store.PetStoreError, ManifestError) as exc:
                return print(_t("pet.adopt_failed", name=arg, error=exc))
            _set_active(arg)
            print(_t("pet.is_out", name=pet.display_name))

    def _handle_hatch_command(self, cmd: str):
        """Generate ("hatch") a new petdex pet from a description: base look, one animation row
        per state, spritesheet, then adopt. Progress streams inline (~a minute of image calls).
        The desktop app opens a richer overlay for this command instead."""
        from agent.pet import store
        from agent.pet.generate import orchestrate
        from agent.pet.generate.imagegen import GenerationError
        from hermes_cli.pets import _set_active
        concept = _command_arg(cmd)
        if not concept:
            # prompt_toolkit owns stdin on this daemon thread — raw input() never renders and eats
            # keystrokes; prefer the thread-aware helper (None when prompting isn't safe).
            # Bare /hatch is dispatched from the process_loop daemon thread while prompt_toolkit owns stdin
            # — a raw input() here types into a prompt that never renders and swallows the next keystrokes
            # (same class as #23185; found in the Aug 2026 full-surface CLI QA sweep: bare /hatch left the
            # session eating input until Ctrl+C). Route through the thread-aware prompt helper, which uses
            # run_in_terminal on the main thread and cancels cleanly (None) when prompting isn't safe.
            prompt_helper = getattr(self, "_prompt_text_input", None)
            try:
                concept = ((prompt_helper or input)(_t("hatch.describe_prompt")) or "").strip()
            except (EOFError, KeyboardInterrupt):
                return print()
        if not concept:
            return print(_t("hatch.usage"))
        # A short, friendly display name from the first few words of the concept.
        display_name = (" ".join(w.capitalize() for w in concept.split()[:3])[:28].strip()
                        or _t("hatch.default_name"))
        slug = store.slugify(display_name) or store.slugify(concept) or "pet"
        print(_t("hatch.designing", concept=concept))
        try:
            drafts = orchestrate.generate_base_drafts(concept, n=1)
        except GenerationError as exc:
            return print(_t("hatch.base_failed", error=exc))
        if not drafts:
            return print(_t("hatch.no_base_draft"))

        def _progress(event: str, detail: str) -> None:
            if event == "row":  # detail is "<state>:<done>:<total>"; show the state name.
                print(f"  {_t('hatch.progress_drawing', state=detail.split(':', 1)[0])}")
            elif event in _HATCH_PROGRESS:
                print(f"  {_t(_HATCH_PROGRESS[event])}")

        try:
            result = orchestrate.hatch_pet(
                base_image=drafts[0], slug=slug, display_name=display_name, concept=concept,
                on_progress=_progress)
        except GenerationError as exc:
            return print(_t("hatch.failed", error=exc))
        _set_active(result.slug)
        print(_t("hatch.hatched", name=result.display_name))

    # ---- /cron ----------------------------------------------------------------------------
    def _handle_cron_command(self, cmd: str):
        """Handle the /cron command to manage scheduled tasks."""
        tokens = shlex.split(cmd)
        if len(tokens) == 1:
            return self._cron_overview()
        subcommand = tokens[1].lower()
        opts = _parse_cron_flags(tokens[2:])
        if opts is None:
            return
        handler = _CRON_SUBCOMMANDS.get(subcommand)
        if handler is None:
            return _pr(_t("cron.unknown_command", subcommand=subcommand), f"  {_t('cron.available')}")
        getattr(self, handler)(subcommand, opts)

    def _cron_overview(self) -> None:
        _pr("", "+" + "-" * 68 + "+", "|" + _t("cron.overview_title").center(68) + "|",
            "+" + "-" * 68 + "+", "", f"  {_t('cron.overview_commands')}",
            *_lines(_t("cron.overview_usage"), pad="    "), "")
        result = _cron_api(action="list")
        jobs = result.get("jobs", []) if result.get("success") else []
        if jobs:
            from hermes_cli.cron import _next_run_row
            _pr(f"  {_t('cron.current_jobs')}", "  " + "-" * 63)
            for job in jobs:
                print(f"    {job['job_id'][:12]:<12} | {job['schedule']:<15} | {job.get('repeat', '?'):<8}")
                if job.get("skills"):
                    print(f"      {_t('cron.skills', skills=', '.join(job['skills']))}")
                print(f"      {job.get('prompt_preview', '')}")
                if job.get("next_run_at"):
                    # A stamp parked past the scheduler grace must not read as upcoming (#114309).
                    label, value = _next_run_row(job)
                    print(f"      {_t('cron.next_short') if label == 'Next run' else label}: {value}")
                print()
        else:
            print(f"  {_t('cron.no_jobs_hint')}")
        print()

    def _cron_list(self, subcommand: str, opts: dict) -> None:
        result = _cron_api(action="list", include_disabled=opts["all"])
        jobs = result.get("jobs", []) if result.get("success") else []
        if not jobs:
            return print(_t("cron.no_jobs"))
        from hermes_cli.cron import _next_run_row
        print()
        _pr(_t("cron.list_header"), "-" * 80)
        for job in jobs:
            _pr(f"  {_t('cron.id', job_id=job['job_id'])}", f"  {_t('cron.name', name=job['name'])}",
                f"  {_t('cron.state', state=job.get('state', '?'))}",
                f"  {_t('cron.schedule_repeat', schedule=job['schedule'], repeat=job.get('repeat', '?'))}",
                "  %s: %s" % _next_run_row(job) if job.get("next_run_at") else f"  {_t('cron.next_run_na')}")
            if job.get("skills"):
                print(f"  {_t('cron.skills', skills=', '.join(job['skills']))}")
            print(f"  {_t('cron.prompt', prompt=job.get('prompt_preview', ''))}")
            if job.get("last_run_at"):
                status = job.get("last_status") or "?"
                # delivery_failed: the run succeeded but delivery didn't — the reason lives
                # in last_delivery_error (last_error is None).
                if status == "delivery_failed" and job.get("last_delivery_error"):
                    status = _t("cron.status_delivery_failed", error=job["last_delivery_error"])
                elif status == "error" and job.get("last_error"):
                    status = _t("cron.status_error", error=job["last_error"])
                print(f"  {_t('cron.last_run', when=job['last_run_at'], status=status)}")
            print()

    def _cron_add(self, subcommand: str, opts: dict) -> None:
        positionals = opts["positionals"]
        if not positionals:
            return print(_t("cron.usage_add"))
        schedule = opts["schedule"] or positionals[0]
        prompt = opts["prompt"] or " ".join(positionals[1:])
        skills = _normalize_skills(opts["skills"])
        if not prompt and not skills:
            return print(_t("cron.need_prompt_or_skill"))
        result = _cron_api(
            action="create", schedule=schedule, prompt=prompt or None, name=opts["name"],
            deliver=opts["deliver"], repeat=opts["repeat"], skills=skills or None)
        if not result.get("success"):
            return print(_t("cron.create_failed", error=result.get("error")))
        _pr(_t("cron.created", job_id=result["job_id"]),
            f"  {_t('cron.schedule', schedule=result['schedule'])}")
        if result.get("skills"):
            print(f"  {_t('cron.skills', skills=', '.join(result['skills']))}")
        print(f"  {_t('cron.next_run', value=result['next_run_at'])}")

    def _cron_edit(self, subcommand: str, opts: dict) -> None:
        from cron import get_job
        positionals = opts["positionals"]
        if not positionals:
            return print(_t("cron.usage_edit"))
        job_id = positionals[0]
        existing = get_job(job_id)
        if not existing:
            return print(_t("cron.job_not_found", job_id=job_id))
        # Skill edit precedence: --clear-skills > --skill (replace) > --add/--remove (merge) > untouched.
        final_skills = None
        replacement_skills = _normalize_skills(opts["skills"])
        add_skills = _normalize_skills(opts["add_skills"])
        remove_skills = set(_normalize_skills(opts["remove_skills"]))
        if opts["clear_skills"]:
            final_skills = []
        elif replacement_skills:
            final_skills = replacement_skills
        elif add_skills or remove_skills:
            existing_skills = list(
                existing.get("skills") or ([existing["skill"]] if existing.get("skill") else []))
            final_skills = [skill for skill in existing_skills if skill not in remove_skills]
            final_skills += [skill for skill in add_skills if skill not in final_skills]
        result = _cron_api(
            action="update", job_id=job_id, schedule=opts["schedule"], prompt=opts["prompt"],
            name=opts["name"], deliver=opts["deliver"], repeat=opts["repeat"], skills=final_skills)
        if not result.get("success"):
            return print(_t("cron.update_failed", error=result.get("error")))
        job = result["job"]
        _pr(_t("cron.updated", job_id=job["job_id"]), f"  {_t('cron.schedule', schedule=job['schedule'])}",
            f"  {_t('cron.skills', skills=', '.join(job['skills']))}" if job.get("skills")
            else f"  {_t('cron.skills_none')}")

    def _cron_job_action(self, subcommand: str, opts: dict) -> None:
        """pause / resume / run / remove (aliases rm, delete) on one job id."""
        positionals = opts["positionals"]
        if not positionals:
            return print(_t("cron.usage_action", subcommand=subcommand))
        job_id = positionals[0]
        action = "remove" if subcommand in {"remove", "rm", "delete"} else subcommand
        result = _cron_api(action=action, job_id=job_id,
                           reason="paused from /cron" if action == "pause" else None)
        if not result.get("success"):
            return print(_t(f"cron.{action}_failed", error=result.get("error")))
        if action == "remove":
            removed = result.get("removed_job", {})
            return print(_t("cron.removed", name=removed.get("name", job_id), job_id=job_id))
        job = result["job"]
        if action == "run" and job.get("execution_skipped"):
            # A refused run-now (claim lost, paused, gone) must not read as accepted.
            return print(_t("cron.did_not_run", name=job["name"], job_id=job_id, reason=job["execution_skipped"]))
        # One full template per action verb (cron.paused / cron.resumed / cron.triggered).
        print(_t({"pause": "cron.paused", "resume": "cron.resumed", "run": "cron.triggered"}[action],
                 name=job["name"], job_id=job_id))
        if action == "resume":
            print(f"  {_t('cron.next_run', value=job.get('next_run_at'))}")
        elif action == "run":
            from hermes_cli.cron import _run_outcome
            print(f"  {_run_outcome(job)}")

    # ---- delegating handlers: /suggestions, /blueprint, /curator, /kanban, /skills, /memory --
    def _handle_suggestions_command(self, cmd: str):
        """Handle /suggestions — review/accept/dismiss suggested automations via the shared handler.
        CLI origin is the local platform so an accepted job's "origin" delivery resolves to a home channel."""
        args = " ".join(_shlex_args(cmd))
        try:
            from hermes_cli.suggestions_cmd import handle_suggestions_command
            output = handle_suggestions_command(args)
        except Exception as e:
            output = _t("suggestions.failed", error=e)
        self._console_print(output)

    def _handle_blueprint_command(self, cmd: str):
        """Handle /blueprint — set up an automation from a blueprint template (shared handler).
        Bare lists the catalog; ``<name>`` seeds the agent to ask for each value conversationally
        (``agent_seed``, run as the next turn); ``<name> slot=val …`` creates the job directly."""
        args = " ".join(shlex.quote(t) for t in _shlex_args(cmd))
        try:
            from hermes_cli.blueprint_cmd import handle_blueprint_command
            result = handle_blueprint_command(args)
        except Exception as e:
            self._console_print(_t("blueprint.failed", error=e))
            return
        self._console_print(result.text)
        seed = getattr(result, "agent_seed", None)
        if seed:
            # One-shot: the interactive loop picks this up right after the slash command
            # returns and runs it as a normal agent turn.
            self._pending_agent_seed = seed

    def _handle_curator_command(self, cmd: str):
        """Handle /curator — delegates to hermes_cli.curator so the CLI and the `hermes curator`
        subcommand share the same handler set."""
        tokens = shlex.split(cmd)[1:] if cmd else []
        try:
            from hermes_cli.curator import cli_main
            cli_main(tokens or ["status"])
        except SystemExit:
            pass  # argparse exits on --help/errors; don't kill the interactive session
        except Exception as exc:
            print(_t("curator.failed", error=exc))

    def _handle_kanban_command(self, cmd: str):
        """Handle /kanban — strip the leading ``/kanban`` and hand the rest to ``kanban.run_slash``."""
        from hermes_cli.kanban import run_slash
        rest = cmd.strip().lstrip("/")
        if rest.startswith("kanban"):
            rest = rest[len("kanban"):].lstrip()
        try:
            output = run_slash(rest)
        except Exception as exc:  # pragma: no cover - defensive
            output = _t("kanban.failed", error=exc)
        if output:
            print(output)

    def _handle_skills_command(self, cmd: str):
        """Handle /skills slash command — delegates to hermes_cli.skills_hub, after intercepting the
        write-approval review subcommands (pending/approve/reject/diff/mode)."""
        from cli import ChatConsole
        args = cmd.strip().split()[1:]
        review_words = {"pending", "approve", "apply", "reject", "deny", "drop", "diff", "approval", "mode"}
        if args and args[0].lower() in review_words:
            from hermes_cli.write_approval_commands import handle_pending_subcommand
            from tools import write_approval as wa
            out = handle_pending_subcommand(
                wa.SKILLS, args, set_mode_fn=lambda enabled: self._save_write_approval("skills", enabled),
            )
            if out is not None:
                return print(out)
        from hermes_cli.skills_hub import handle_skills_slash
        handle_skills_slash(cmd, ChatConsole())

    def _handle_memory_command(self, cmd: str):
        """Handle /memory slash command — pending review + approval-gate toggle."""
        from hermes_cli.write_approval_commands import handle_pending_subcommand
        from tools import write_approval as wa
        args = cmd.strip().split()[1:]
        store = getattr(self.agent, "_memory_store", None) if getattr(self, "agent", None) else None
        if store is None:
            # No live agent store (e.g. Desktop GUI): use a fresh on-disk store, as the gateway
            # does — same MEMORY/USER.md, same configured char limits.
            # Apply against a freshly loaded on-disk store, mirroring the gateway path
            # (gateway/slash_commands.py): it persists to the same MEMORY/USER.md and creates MEMORY.md on
            # the first approved write. Without this the shared handler returns "memory store unavailable".
            # See #46783.
            from tools.memory_tool import load_on_disk_store
            store = load_on_disk_store()
        out = handle_pending_subcommand(
            wa.MEMORY, args, memory_store=store,
            set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled))
        print(out if out is not None else _t("memory.unknown_subcommand"))

    def _save_write_approval(self, subsystem: str, enabled: bool):
        """Persist <subsystem>.write_approval to config (for /memory|/skills approval)."""
        _save(f"{subsystem}.write_approval", bool(enabled))

    # ---- prompt-queueing handlers: /learn, /plan, /init -----------------------------------
    def _queue_prompt_turn(self, msg: str, command: str) -> None:
        """Inject ``msg`` onto the agent's input queue as the next normal user turn (the
        /learn, /plan, /init pattern: no engine, no model-tool footprint, prompt-cache safe)."""
        if hasattr(self, "_pending_input"):
            self._pending_input.put(msg)
        else:  # pragma: no cover - defensive (no live input loop)
            print(f"  {_t('shared.needs_active_session', command=command)}")

    def _handle_learn_command(self, cmd: str):
        """Handle /learn — distill a reusable skill from anything the user describes (a directory,
        a URL, "what we just did", pasted notes). The live agent gathers the material with the
        tools it already has and authors the skill via ``skill_manage``."""
        from agent.learn_prompt import build_learn_prompt
        user_request = _command_arg(cmd)
        print("\n" + _t("learn.from_description" if user_request else "learn.from_conversation"))
        self._queue_prompt_turn(build_learn_prompt(user_request), "/learn")

    def _handle_plan_command(self, cmd: str):
        """Handle /plan — write a markdown implementation plan, no execution. The live agent
        inspects the workspace read-only and saves the plan under ``.hermes/plans/``."""
        from agent.plan_prompt import build_plan_prompt
        task = _command_arg(cmd)  # optional — empty infers the task from conversation context
        print("\n" + (_t("plan.planning", task=_ellipsize(task, 80)) if task else _t("plan.from_conversation")))
        self._queue_prompt_turn(build_plan_prompt(task), "/plan")

    def _handle_init_command(self, cmd: str):
        """Handle /init — generate or update AGENTS.md from a project scan performed by the
        live agent with its own read-only tools."""
        from hermes_cli.init_command import build_init_prompt_for_cwd
        # session_key="" targets the single-session CLI's "default" cwd record, which tracks
        # `cd` and workspace switches, so /init follows the directory the user works in.
        msg = build_init_prompt_for_cwd(extra=_command_arg(cmd), session_key="")  # optional user emphasis
        print("\n" + _t("init.updating" if "UPDATE the existing AGENTS.md" in msg else "init.generating"))
        self._queue_prompt_turn(msg, "/init")

    # ---- side-session handlers: /bg, /btw -------------------------------------------------
    def _handle_background_command(self, cmd: str):
        """Handle /bg <prompt> — run a prompt in a separate background session (its own AIAgent
        on a thread); the result prints here without touching the active history."""
        from cli import set_approval_callback, set_secret_capture_callback, set_sudo_password_callback
        from run_agent import AIAgent
        prompt = _command_arg(cmd)
        if not prompt:
            return _cp(*_lines(_t("background.usage")))
        self._background_task_counter += 1
        task_num = self._background_task_counter
        task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}"
        if not self._ensure_runtime_credentials():
            return _cp(f"  {_t('background.no_credentials')}")
        preview = _ellipsize(prompt, 60)
        _cp(f"  {_t('background.started', number=task_num, preview=preview)}",
            f"  {_t('background.task_id', task_id=task_id)}", f"  {_t('background.keep_chatting')}\n")
        turn_route = self._resolve_turn_agent_config(prompt)
        runtime = turn_route["runtime"]

        def produce():
            from agent.vault_backends.unlock import set_code_prompt_callback, set_save_login_prompt_callback, set_unlock_prompt_callback
            set_sudo_password_callback(self._sudo_password_callback)
            set_approval_callback(self._approval_callback)
            set_unlock_prompt_callback(self._vault_unlock_callback)
            set_save_login_prompt_callback(self._vault_save_login_callback)
            set_code_prompt_callback(self._vault_code_callback)
            with suppress(Exception):
                set_secret_capture_callback(self._secret_capture_callback)
            try:
                bg_agent = AIAgent(
                    model=turn_route["model"], acp_command=runtime.get("command"),
                    acp_args=runtime.get("args"), max_iterations=self.max_turns,
                    **{k: runtime.get(k) for k in ("api_key", "base_url", "provider", "api_mode",
                                                   "max_tokens")}, enabled_toolsets=self.enabled_toolsets,
                    quiet_mode=True, verbose_logging=False, session_id=task_id, platform="cli",
                    side_agent=True,
                    session_db=self._session_db, reasoning_config=self.reasoning_config,
                    service_tier=self.service_tier,
                    request_overrides=turn_route.get("request_overrides"),
                    **{kw: getattr(self, attr) for kw, attr in _BG_PROVIDER_KWARGS.items()})
                # Silence raw spinner; route thinking through TUI widget when no foreground agent is active.
                bg_agent._print_fn = lambda *_a, **_kw: None

                def _bg_thinking(text: str) -> None:
                    # Concurrent bg tasks may race on _spinner_text; acceptable for best-effort UI.
                    if not self._agent_running:
                        self._spinner_text = text
                        if self._app:
                            # Display result in the CLI (thread-safe via patch_stdout). Force a TUI refresh
                            # first so spinner/status bar don't overlap with the output (fixes #2718).
                            # Same TUI refresh pattern as success path (#2718)
                            self._app.invalidate()

                bg_agent.thinking_callback = _bg_thinking
                try:
                    result = bg_agent.run_conversation(user_message=prompt, task_id=task_id)
                    response = result.get("final_response", "") if result else ""
                    if not response and result and result.get("error"):
                        response = _gt("model.error_prefix", error=result["error"])
                    return response
                finally:
                    # One agent per /bg task in a long-lived CLI process: close()
                    # is the owner boundary (memory shutdown, tool subprocesses,
                    # httpx clients); an unclosed side agent leaks all of them
                    # until the CLI exits (#50197).
                    with suppress(Exception):
                        bg_agent.close()
            finally:
                with suppress(Exception):
                    set_sudo_password_callback(None)
                    set_approval_callback(None)
                    set_secret_capture_callback(None)
                    set_unlock_prompt_callback(None)
                    set_save_login_prompt_callback(None)
                    set_code_prompt_callback(None)

        def done():
            self._background_tasks.pop(task_id, None)
            if not self._agent_running:  # clear spinner only if no foreground agent owns it
                self._spinner_text = ""

        thread = self._side_worker(
            produce, name=f"bg-task-{task_id}", fail_label=_t("background.label", number=task_num),
            header_lines=[f"  {_t('background.complete', number=task_num)}",
                          f"  {_t('background.prompt', preview=preview)}"],
            title_suffix=_t("background.title_suffix", number=task_num),
            empty_note=f"  {_t('background.no_response')}", bell=True, on_done=done)
        self._background_tasks[task_id] = thread
        thread.start()

    def _side_worker(self, produce, *, name, fail_label, header_lines, title_suffix, empty_note,
                     bell=False, on_done=None, console=None) -> threading.Thread:
        """Daemon thread for /bg, /btw and /login: ``produce()`` returns the body to print in a side-result
        panel; failures print ``fail_label`` failed; the TUI is always re-invalidated afterwards."""
        def run():
            try:
                body = produce()
                _print_side_result_panel(self, header_lines=header_lines, body=body,
                                         title_suffix=title_suffix, empty_note=empty_note,
                                         console=console)
                if bell:
                    self._ring_bell(context=_t("side_result.complete", label=fail_label))
            except Exception as e:
                _refresh_tui_before_print(self)
                line = f"  {_t('side_result.failed', label=fail_label, error=e)}"
                # Same console the caller captured, so a late failure can't splice into a later command.
                if console is not None:
                    console.print(line, markup=False)
                else:
                    _cp(line)
            finally:
                if on_done is not None:
                    on_done()
                if self._app:
                    self._invalidate(min_interval=0)

        return threading.Thread(target=run, daemon=True, name=name)

    def _handle_login_command(self, cmd_original: str) -> None:
        """Start an in-chat sign-in without blocking the input loop while approval is pending."""
        from hermes_cli import anon_auth
        # Pin the output target now. Under the live TUI ``self.console`` writes straight to
        # patch_stdout's StdoutProxy, which mangles Rich's escapes — there ``None`` keeps the
        # panel on the ``_cprint`` path. Only the slash worker (``_app`` is None) swaps the console.
        console = None if getattr(self, "_app", None) else getattr(self, "console", None)
        _cp(f"  {anon_auth.LOGIN_STARTING}")
        gen = anon_auth.run_sign_in(timeout_seconds=8.0)
        try:
            first = next(gen, None)
        except KeyboardInterrupt:
            with suppress(Exception):
                gen.close()
            return _cp(anon_auth.UPGRADE_CANCELLED)
        if first is None:
            return
        if first.terminal:
            return _cp(f"  {first.copy}")
        anon_auth.render_sign_in_cli_code(first, chat=True, printer=_cp)

        def _settle_session_model(state) -> None:
            """A completed sign-in moved this profile onto the account: the welcome host is gone and
            the portal serves ``nous/welcome`` as a paid model, so a session still carrying it must
            move too — the CLI counterpart of the gateway's on-``Completed`` sweep. Only the free
            tier's own model is replaced; a model the user picked while the sign-in was pending
            stands. Writing ``self.model`` is enough: ``chat()`` compares the turn-route signature
            and rebuilds the agent on the next turn, so a turn already in flight keeps the agent it
            started with. ``getattr``: tests drive this handler with minimal shells.
            """
            if state.kind != "completed" or not getattr(state, "model_changed", False):
                return
            if str(getattr(self, "model", "") or "") == anon_auth.GUEST_MODEL:
                # "" when the settle cleared the default: _ensure_runtime_credentials then applies
                # the provider's silent default, which is what settle_after_upgrade documents.
                self.model = state.model or ""

        thread = self._side_worker(
            lambda: anon_auth.drain_sign_in_copy(gen, chat=True, on_terminal=_settle_session_model),
            name="login", fail_label=_t("login.label"), header_lines=[f"  {_t('login.label')}"],
            title_suffix=_t("login.title_suffix"), empty_note=f"  {_t('login.no_result')}", console=console)
        thread.start()

    def _handle_btw_command(self, cmd: str):
        """Handle /btw <question> — answer a side question about this conversation from a
        history snapshot via a one-shot auxiliary call. The live session is never touched
        (no history mutation, no role-alternation risk, no cache invalidation)."""
        question = _command_arg(cmd)
        if not question:
            return _cp(*_lines(_t("btw.usage")))
        if not self._ensure_runtime_credentials():
            return _cp(f"  {_t('btw.no_credentials')}")
        # Snapshot NOW, on the UI thread — the foreground turn keeps appending to
        # conversation_history while the worker runs.
        history_snapshot = list(self.conversation_history or [])
        # Live agent → cache-parity fork (full context, warm cache reads).
        parent_agent = self.agent
        turn_route = self._resolve_turn_agent_config(question)
        runtime = turn_route["runtime"]
        main_runtime = {
            "model": turn_route["model"],
            **{k: runtime.get(k) for k in ("provider", "base_url", "api_key", "api_mode")},
            "session_id": getattr(parent_agent, "session_id", None),
        }
        preview = _ellipsize(question, 60)
        _cp(*_lines(_gt("btw.started", preview=preview)), "")

        def produce():
            from agent.side_question import answer_side_question
            return answer_side_question(
                question, history_snapshot, parent_agent=parent_agent, main_runtime=main_runtime)

        self._side_worker(produce, name="btw-side-question", fail_label="/btw",
                          header_lines=[f"  {_t('btw.header', preview=preview)}"],
                          title_suffix=_t("btw.title_suffix"),
                          empty_note=f"  {_t('btw.no_answer')}").start()

    # ---- /bundles, /browser ---------------------------------------------------------------
    def _handle_bundles_command(self, cmd: str) -> None:
        """In-session ``/bundles`` — show installed skill bundles (``hermes bundles list`` rendered
        inside the running CLI). Bundles are loaded via ``/<bundle-name>``."""
        from cli import ChatConsole, _BOLD, _RST, _accent_hex
        from hermes_cli.slash_exec import CommandContext, execute_command
        reply = execute_command("bundles", CommandContext(surface="cli"))
        if "error" in reply.data:
            return _cp(f"\033[1;31m{_t('bundles.unavailable', error=reply.data['error'])}{_RST}")
        bundles = reply.data["bundles"]
        if not bundles:
            return _cp(f"  {_t('bundles.none_installed')}", _dim_line(_t("bundles.create_hint")),
                       _dim_line(_t("bundles.directory", dir=reply.data["dir"])))
        _cp(f"\n  ▣ {_BOLD}{_t('bundles.title')}{_RST} {_t('bundles.installed_count', count=len(bundles))}")
        for info in bundles:
            skill_count = len(info.get("skills", []))
            desc = info.get("description") or _t("bundles.default_desc", count=skill_count)
            ChatConsole().print(
                f"    [bold {_accent_hex()}]/{info['slug']:<20}[/] "
                f"[dim]-[/] {_escape(desc)} [dim]{_escape(_t('bundles.skills_count', count=skill_count))}[/]")
            for s in info.get("skills", []):
                ChatConsole().print(f"        [dim]· {_escape(s)}[/]")
        _cp("\n" + _dim_line(_t("bundles.invoke_hint")))

    def _handle_browser_command(self, cmd: str):
        """Handle /browser connect|disconnect|status|use — manage the live Chromium-family CDP connection."""
        # The subcommand word is matched case-insensitively; the raw argument keeps
        # its case because a CDP URL's path segment is case-sensitive.
        parts = _command_arg(cmd).split(None, 1)
        word = parts[0].lower() if parts else "status"
        rest = parts[1].strip() if len(parts) > 1 else ""
        handler = _BROWSER_SUBCOMMANDS.get(word)
        if handler is None:
            _say_block(
                _t("browser.usage"), "",
                *(f"   {_t(f'browser.usage_{sub}')}" for sub in ("connect", "disconnect", "status", "use")))
            return
        handler(self, rest.strip())

    # ---- /heartbeat, /refine, /review -----------------------------------------------------
    def _session_manager(self, getter, label: str):
        """The session-scoped manager from ``getter()``, or None after the standard dim
        "<label> unavailable (no active session)." line."""
        mgr = getter()
        if mgr is None:
            _cp(_dim_line(_t("shared.unavailable_no_session", label=label)))
        return mgr

    def _handle_heartbeat_command(self, cmd: str) -> None:
        """Dispatch /heartbeat: set / status / pause / resume / clear. ``/heartbeat every 10m <prompt>``
        sets the session's one recurring instruction, injected as a normal user turn when due.
        Session-scoped and in-process — use `hermes cron` for durable schedules."""
        from hermes_cli.heartbeat import format_interval
        arg = _command_arg(cmd)
        lower = arg.lower()
        mgr = self._session_manager(self._get_heartbeat_manager, _t("heartbeat.label"))
        if mgr is None:
            return
        if not arg or lower == "status":
            _cp(f"  {mgr.status_line()}")
        elif lower == "pause":
            state = mgr.pause()
            _cp(f"  {_t('heartbeat.paused', prompt=state.prompt)}" if state
                else _dim_line(_t("heartbeat.none_set")))
        elif lower == "resume":
            state = mgr.resume()
            if state is None:
                _cp(_dim_line(_t("heartbeat.none_to_resume")))
            else:
                self._start_heartbeat_watchdog()
                _cp(f"  {_t('heartbeat.resumed', interval=format_interval(state.interval_seconds), prompt=state.prompt)}")
        elif lower in {"clear", "stop", "off"}:
            _cp(f"  {_t('heartbeat.cleared')}" if mgr.clear() else _dim_line(_t("heartbeat.none_set")))
        else:
            self._heartbeat_set(mgr, arg)

    def _heartbeat_set(self, mgr, arg: str) -> None:
        """Set: ``/heartbeat every 10m <prompt>`` (also accepts ``10m <prompt>``)."""
        from hermes_cli.heartbeat import parse_interval, format_interval
        tokens = arg.split(None, 2)
        interval = None
        prompt = ""
        if tokens and tokens[0].lower() == "every" and len(tokens) >= 2:
            interval = parse_interval(f"every {tokens[1]}")
            prompt = tokens[2] if len(tokens) > 2 else ""
        elif tokens:
            interval = parse_interval(tokens[0])
            prompt = arg[len(tokens[0]):].strip() if interval and interval > 0 else ""
        if interval is None:
            return _cp(f"  {_t('heartbeat.usage')}", _dim_line(_t("heartbeat.usage_also")))
        if interval < 0:
            from hermes_cli.heartbeat import MIN_INTERVAL_SECONDS
            return _cp(f"  {_t('heartbeat.interval_too_small', min_seconds=MIN_INTERVAL_SECONDS)}")
        if not prompt.strip():
            return _cp(f"  {_t('heartbeat.prompt_required')}")
        state = _attempt(_t("heartbeat.invalid_label"), ValueError, mgr.set, prompt, interval)
        if state is _FAILED:
            return
        self._start_heartbeat_watchdog()
        _cp(f"  {_t('heartbeat.set', interval=format_interval(state.interval_seconds), prompt=state.prompt)}",
            _dim_line(_t("heartbeat.set_note")))

    def _handle_refine_command(self, cmd: str) -> None:
        """Dispatch /refine — run the memory/skill review fork on demand (same machinery as the
        automatic post-turn ``_spawn_background_review``), with optional focus text. Background
        fork; the live conversation and prompt cache are never touched."""
        focus = _command_arg(cmd)
        agent = getattr(self, "agent", None)
        if agent is None:
            return _cp(_dim_line(_t("refine.nothing_yet")))
        snapshot = list(getattr(self, "conversation_history", None) or [])
        if not snapshot:
            return _cp(_dim_line(_t("refine.empty")))
        try:
            agent._spawn_background_review(
                messages_snapshot=snapshot, review_memory=True,
                review_skills="skill_manage" in getattr(agent, "valid_tool_names", set()),
                focus=focus or None, explicit=True)
        except Exception as exc:
            return _cp(f"  {_t('refine.failed', error=exc)}")
        tail = _t("refine.focus_suffix", focus=focus) if focus else ""
        _cp(f"  {_t('refine.started', focus=tail)}")

    def _handle_review_command(self, cmd: str) -> None:
        """Dispatch /review — snapshot the last N messages (+ argument text as instructions) and
        spawn an independent reviewer subagent via async delegation; the review re-enters this
        session as a normal delegation completion."""
        prompt = _command_arg(cmd)
        agent = getattr(self, "agent", None)
        if agent is None:
            return _cp(_dim_line(_t("review.nothing_yet")))
        snapshot = list(getattr(self, "conversation_history", None) or [])
        try:
            from agent.review_engine import format_dispatch_note, start_review
            result = start_review(agent, snapshot, prompt)
        except ValueError as exc:
            return _cp(_dim_line(str(exc)))
        except Exception as exc:
            return _cp(f"  {_t('review.failed', error=exc)}")
        _cp(f"  {format_dispatch_note(result, prompt)}")

    # ---- /goal, /loop, /subgoal -----------------------------------------------------------
    def _handle_goal_command(self, cmd: str) -> None:
        from hermes_cli.goal_command import dispatch_goal_command
        from hermes_cli.goals import last_user_message_content

        mgr = self._session_manager(self._get_goal_manager, _t("goal.label"))
        if mgr is None:
            return
        result = dispatch_goal_command(
            mgr, _command_arg(cmd), authorize_gate=lambda: None,
            progress=lambda text: _cp(_dim_line(text)),
            last_user_message=last_user_message_content(getattr(self, "conversation_history", None)),
        )
        for line in result.output.splitlines():
            _cp(f"  {line}")
        if result.prompt:
            queued = self._kick_goal(result.prompt)
            if not result.kickoff:
                _cp(_dim_line(_t("goal.continuing") if queued else _t("goal.send_to_kick")))

    def _kick_goal(self, prompt: str) -> bool:
        """Queue the next turn without mutating cached conversation history."""
        try:
            self._pending_input.put(prompt)
            return True
        except Exception:
            return False

    def _handle_loop_command(self, cmd: str) -> None:
        """Dispatch /loop — recurring in-session wakeups: ``/loop [interval] <prompt> [--times N]
        [--until <cond>]`` starts one; ``status | pause | resume | stop`` control it."""
        arg = _command_arg(cmd)
        mgr = self._session_manager(self._get_loop_manager, _t("loop.label"))
        if mgr is None:
            return
        from hermes_cli.loops import dispatch_loop_command
        result = dispatch_loop_command(mgr, arg)
        for line in (result.get("output") or "").splitlines():
            _cp(f"  {line}")
        if result.get("created"):
            with suppress(Exception):
                from hermes_cli.loops import goal_blocks_loop_tick
                if goal_blocks_loop_tick(mgr.session_id):
                    _cp(_dim_line(_t("loop.goal_defers_note")))

    def _handle_subgoal_command(self, cmd: str) -> None:
        """Dispatch /subgoal: bare → show, ``<text>`` → append, ``remove <n>`` (1-based), ``clear``.
        Subgoals join the judge + continuation prompts at the next turn boundary (no kick)."""
        parts = (cmd or "").strip().split(None, 2)
        arg = " ".join(parts[1:]).strip() if len(parts) > 1 else ""
        mgr = self._session_manager(self._get_goal_manager, _t("goal.label"))
        if mgr is None:
            return
        if not mgr.has_goal():
            return _cp(_dim_line(_t("subgoal.no_goal")))
        if not arg:  # list current subgoals
            _cp(f"  {mgr.status_line()}")
            return _cp(f"  {mgr.render_subgoals()}")
        tokens = arg.split(None, 1)
        verb = tokens[0].lower()
        rest = tokens[1].strip() if len(tokens) > 1 else ""
        if verb == "remove":
            if not rest:
                return _cp(f"  {_t('subgoal.usage_remove')}")
            try:
                idx = int(rest.split()[0])
            except ValueError:
                return _cp(f"  {_t('subgoal.remove_needs_int')}")
            removed = _attempt("/subgoal remove", (IndexError, RuntimeError), mgr.remove_subgoal, idx)
            if removed is not _FAILED:
                _cp(f"  {_t('subgoal.removed', index=idx, text=removed)}")
        elif verb == "clear":
            prev = _attempt("/subgoal clear", RuntimeError, mgr.clear_subgoals)
            if prev is not _FAILED:
                _cp("  " + _tn("subgoal.cleared", prev) if prev else _dim_line(_t("subgoal.none_to_clear")))
        else:  # append the whole arg as a new subgoal
            text = _attempt("/subgoal", (ValueError, RuntimeError), mgr.add_subgoal, arg)
            if text is not _FAILED:
                idx = len(mgr.state.subgoals) if mgr.state else 0
                _cp(f"  {_t('subgoal.added', index=idx, text=text)}")

    # ---- /skin, /prompt -------------------------------------------------------------------
    def _handle_skin_command(self, cmd: str):
        """Handle /skin [name] — show or change the display skin."""
        from cli import _ACCENT
        try:
            from hermes_cli.skin_engine import list_skins, set_active_skin, get_active_skin_name
        except ImportError:
            return print(_t("skin.unavailable"))
        new_skin = _command_arg(cmd).lower()
        if not new_skin:  # show current skin and list available
            current = get_active_skin_name()
            _pr(f"\n  {_t('skin.current', name=current)}", f"  {_t('skin.available_header')}")
            for s in list_skins():
                marker = " ●" if s["name"] == current else "  "
                source = f" ({s['source']})" if s["source"] == "user" else ""
                print(f"   {marker} {s['name']}{source} — {s['description']}")
            return _pr(f"\n  {_t('skin.usage')}",
                       f"  {_t('skin.custom_hint', dir=display_hermes_home())}\n")
        available = {s["name"] for s in list_skins()}
        if new_skin not in available:
            return _pr(f"  {_t('skin.unknown', name=new_skin)}",
                       f"  {_t('skin.available', names=', '.join(sorted(available)))}")
        set_active_skin(new_skin)
        _ACCENT.reset()  # re-resolve ANSI color for the new skin (_DIM is a fixed escape)
        saved = _t("skin.saved_suffix") if _save("display.skin", new_skin) else ""
        _pr(f"  {_t('skin.set_to', name=new_skin, saved=saved)}", f"  {_t('skin.banner_note')}")
        if self._apply_tui_skin_style():
            print(f"  {_t('skin.colors_updated')}")

    def _compose_in_editor(self, initial_text: str = "") -> str:
        """Open ``$VISUAL``/``$EDITOR`` on a temp markdown file and return the saved buffer with
        ``#!`` comment lines stripped; "" if the editor failed or the buffer was left empty.
        Factored out so the read-back/strip logic is unit-testable."""
        editor = (os.environ.get("VISUAL") or os.environ.get("EDITOR")
                  or ("notepad" if os.name == "nt" else "nano"))
        fd, path = tempfile.mkstemp(suffix=".md", prefix="hermes_prompt_")
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as fh:
                fh.write(_t("prompt_compose.editor_header") + "\n\n")
                if initial_text:
                    fh.write(initial_text)
            try:
                editor_argv = [*shlex.split(editor), path]
            except ValueError:
                return ""  # unbalanced quotes in $EDITOR: cancel, never retry through a shell
            try:
                status = subprocess.call(editor_argv)
            except OSError:
                return ""  # editor not runnable: cancel the compose (#81364)
            # A failed editor may leave seeded or abandoned text in the buffer.
            if status != 0:
                return ""
            with open(path, "r", encoding="utf-8-sig") as fh:
                raw = fh.read()
        finally:
            with suppress(OSError):
                os.unlink(path)
        return "\n".join(ln for ln in raw.splitlines() if not ln.startswith("#!")).strip()

    def _handle_prompt_compose_command(self, cmd_original: str) -> None:
        """Handle /prompt — compose the next prompt in $EDITOR (optionally seeded with the argument)
        and queue it as the next agent turn via the one-shot ``_pending_agent_seed`` (as /blueprint)."""
        parts = (cmd_original or "").strip().split(None, 1)
        try:
            composed = self._compose_in_editor(parts[1] if len(parts) > 1 else "")
        except Exception as exc:
            return _cp(_dim_line(_t("prompt_compose.editor_failed", error=exc)))
        if not composed:
            return _cp(_dim_line(_t("prompt_compose.empty")))
        # One-shot seed: the interactive loop runs this as the next agent turn right after
        # process_command() returns (see cli.py main loop).
        self._pending_agent_seed = composed

    # ---- /focus and its display hooks -----------------------------------------------------
    def _handle_focus_command(self, cmd_original: str) -> None:
        """``/focus [on|off|status]`` — DISPLAY-ONLY reduced output. Reuses the ``/verbose``
        suppression path: on stashes tool_progress_mode and snaps it to "off" (what
        ``agent/tool_executor.py`` gates on); off restores the stash verbatim. Never touches
        history, system prompt, or request payloads."""
        from hermes_cli.colors import Colors as _Colors
        from hermes_cli.focus_view import (
            FOCUS_CONFIG_KEY, FOCUS_TOOL_PROGRESS_MODE, format_focus_status,
            format_focus_toggle_message, normalize_tool_progress_mode, resolve_focus_arg)
        current = bool(getattr(self, "_focus_view_enabled", False))
        action, target = resolve_focus_arg(_command_arg(cmd_original), current)
        if action == "usage":
            return _cp(f"  {_t('focus.usage')}")
        # The mode /focus off restores: while focus is ON the live mode is "off", so use the stash.
        restore_mode = normalize_tool_progress_mode(
            getattr(self, "_focus_saved_tool_progress", None) if current
            else getattr(self, "tool_progress_mode", "all"))
        if action == "status":
            head, _, tail = format_focus_status(current, restore_mode).partition("\n")
            label, _, rest = head.partition(":")
            state_color = _Colors.GREEN if current else _Colors.DIM
            return _cp(f"  {_Colors.BOLD}{label}:{_Colors.RESET}{state_color}{rest}{_Colors.RESET}"
                       + (f"\n{_Colors.DIM}  {tail.strip()}{_Colors.RESET}" if tail else ""))
        if target == current:  # idempotent explicit set — report without rewriting config
            return _cp(f"  {format_focus_toggle_message(current, restore_mode)}")
        if target:
            # Stash the user's configured mode, then reuse the EXISTING suppression path by
            # snapping to "off".
            self._focus_saved_tool_progress = restore_mode
            self._set_tool_progress_mode(FOCUS_TOOL_PROGRESS_MODE)
        else:
            self._set_tool_progress_mode(restore_mode)
            self._focus_saved_tool_progress = None
        self._focus_view_enabled = bool(target)
        self._focus_hidden_lines = 0
        _save(FOCUS_CONFIG_KEY, bool(target))
        message = format_focus_toggle_message(bool(target), restore_mode)
        # Re-colour just the enabled/disabled word so the line matches siblings.
        for word in ("enabled", "disabled"):
            if word in message:
                colour = _Colors.GREEN if target else _Colors.DIM
                message = message.replace(word, f"{colour}{word}{_Colors.RESET}", 1)
                break
        _cp(f"  {message}")

    def _set_tool_progress_mode(self, mode: str) -> None:
        """Set the live tool-progress mode on both the CLI and the agent (one write path for
        /focus and /verbose — the agent copy is what ``agent/tool_executor.py`` gates on)."""
        from hermes_cli.focus_view import normalize_tool_progress_mode
        normalized = normalize_tool_progress_mode(mode)
        self.tool_progress_mode = normalized
        agent = getattr(self, "agent", None)
        if agent is not None:
            with suppress(Exception):
                agent.tool_progress_mode = normalized

    def _note_focus_hidden_line(self, function_name: str) -> None:
        """Count one tool line focus view is suppressing this turn — against the mode the user had
        BEFORE focus snapped to "off", so a prior ``/verbose off`` user is never told focus hid lines."""
        if not getattr(self, "_focus_view_enabled", False):
            return
        from hermes_cli.focus_view import would_display_tool_line
        saved = getattr(self, "_focus_saved_tool_progress", None)
        last = getattr(self, "_focus_last_counted_tool", None)
        if not would_display_tool_line(saved, function_name, last):
            return
        self._focus_last_counted_tool = function_name
        self._focus_hidden_lines = int(getattr(self, "_focus_hidden_lines", 0)) + 1

    def _emit_focus_recovery_line(self) -> None:
        """Print the dim post-turn recovery line and reset the counter."""
        count = int(getattr(self, "_focus_hidden_lines", 0) or 0)
        self._focus_hidden_lines = 0
        self._focus_last_counted_tool = None
        if not getattr(self, "_focus_view_enabled", False):
            return
        from hermes_cli.focus_view import format_hidden_line
        line = format_hidden_line(count)
        if line:
            with suppress(Exception):
                _cp(_dim_line(line))

    # ---- persisted display toggles: /approvals, /footer, /timestamps ----------------------
    def _handle_approvals_command(self, cmd_original: str) -> None:
        """Show or persist the profile-wide dangerous-command approval mode."""
        from hermes_cli.approval_mode import run_approval_mode_command
        parts = (cmd_original or "").strip().split(None, 1)
        result = run_approval_mode_command(parts[1] if len(parts) > 1 else None)
        _cp(f"  {result.message}")

    def _toggle_setting(self, arg: str, current: bool, *, usage: str, status_line: str,
                        config_key: str, label: str, failed: str):
        """Shared /footer + /timestamps flow: status query, usage error, or save + report.
        Returns the new bool state when it was saved (None otherwise)."""
        from hermes_cli.colors import Colors as _Colors
        new_state = _toggle_target(arg, current)
        if new_state == "status":
            state = _t("shared.state_on") if current else _t("shared.state_off")
            return _cp(f"  {_Colors.BOLD}{label}:{_Colors.RESET} {state}{status_line}")
        if new_state is None:
            return _cp(f"  {_t('shared.usage', usage=usage)}")
        if _save(config_key, new_state):
            colour = _Colors.GREEN if new_state else _Colors.DIM
            state = _t("shared.state_on") if new_state else _t("shared.state_off")
            _cp(f"  {label}: {colour}{state}{_Colors.RESET}")
        else:
            _cp(f"  {_t('toggle.save_failed', setting=failed)}")
        return new_state

    def _handle_footer_command(self, cmd_original: str) -> None:
        """Toggle or inspect ``display.runtime_footer.enabled`` (``/footer [on|off|status]``)."""
        from hermes_cli.config import load_config
        footer_cfg = (((load_config() or {}).get("display") or {}).get("runtime_footer") or {})
        fields = footer_cfg.get("fields") or ["model", "context_pct", "cwd"]
        self._toggle_setting(
            _command_arg(cmd_original, lower=True), bool(footer_cfg.get("enabled", False)),
            usage=_t("footer.usage"), status_line=f"\n  {_t('footer.fields', fields=', '.join(fields))}",
            config_key="display.runtime_footer.enabled", label=_t("footer.label"), failed="runtime_footer",
        )

    def _handle_timestamps_command(self, cmd_original: str) -> None:
        """Toggle or inspect ``display.timestamps`` (``/timestamps [on|off|status]``). When on,
        message labels carry an ``[HH:MM]`` suffix and ``/history`` prefixes stored-timestamp turns."""
        arg = _command_arg(cmd_original, lower=True)
        current = bool(getattr(self, "show_timestamps", False))
        new_state = _toggle_target(arg, current)
        if isinstance(new_state, bool):
            self.show_timestamps = new_state
        self._toggle_setting(
            arg, current, usage=_t("timestamps.usage"), status_line="",
            config_key="display.timestamps", label=_t("timestamps.label"), failed="timestamps")

    # ---- model-behaviour settings: /reasoning, /busy, /indicator, /fast -------------------
    def _handle_reasoning_command(self, cmd: str):
        """Handle /reasoning [<level> [--global]|show|hide|full|clamp] — effort level (session
        scope unless --global) and thinking display toggles (always saved)."""
        from cli import CLI_CONFIG, _parse_reasoning_config
        from agent.reasoning_effort import effort_display_label
        raw = _command_arg(cmd)
        _route = (getattr(self, "provider", None), getattr(self, "model", None))
        if not raw:  # show current state
            rc = self.reasoning_config
            level = (_gt("reasoning.level_default") if rc is None else _gt("reasoning.level_disabled")
                     if rc.get("enabled") is False else effort_display_label(rc.get("effort", "medium"), *_route))
            display_state = _t("reasoning.display_on") if self.show_reasoning else _t("reasoning.display_off")
            full_state = (_t("reasoning.full_state_full") if getattr(self, "reasoning_full", False)
                          else _t("reasoning.full_state_clamped"))
            return _cp(_accent_line(_t("reasoning.effort_line", level=level)),
                       _accent_line(_t("reasoning.display_line", display=display_state, full=full_state)),
                       _dim_line(_t("reasoning.usage")))
        arg, explicit_global = _split_scope_flags(raw)
        toggle = _REASONING_TOGGLES.get(arg)
        if toggle is not None:  # display show/hide or full/clamp recap toggle
            attr, value, headline_key, note_key = toggle
            setattr(self, attr, value)
            if attr == "show_reasoning" and self.agent:
                self.agent.reasoning_callback = self._current_reasoning_callback()
            _save(f"display.{attr}", value)
            _cp(_accent_line(_t("reasoning.display_saved", headline=_t(f"reasoning.{headline_key}"))))
            if note_key:
                _cp(_dim_line(f"  {_t(f'reasoning.{note_key}')}"))
            if attr == "reasoning_full" and value and not self.show_reasoning:
                _cp(_dim_line(f"  {_t('reasoning.display_off_note')}"))
            return
        # Effort level change
        parsed = _parse_reasoning_config(arg)
        if parsed is None:
            return _cp(_dim_line(_t("shared.unknown_argument", arg=arg)),
                       _dim_line(_t("reasoning.valid_levels")), _dim_line(_t("reasoning.valid_display")),
                       _dim_line(_t("reasoning.valid_scope")))
        self.reasoning_config = parsed
        _retire_agent(self)  # Force agent re-init with new reasoning config
        saved = explicit_global and _save("agent.reasoning_effort", arg)
        if saved:
            if not isinstance(CLI_CONFIG.get("agent"), dict):
                CLI_CONFIG["agent"] = {}
            CLI_CONFIG["agent"]["reasoning_effort"] = arg
        _cp(_accent_line(_t("reasoning.effort_set", effort=effort_display_label(arg, *_route),
                            scope=_scope_outcome(explicit_global, saved))))

    def _handle_busy_command(self, cmd: str):
        """Handle /busy [status|queue|steer|interrupt] — what Enter does while Hermes is working."""
        arg = _command_arg(cmd, lower=True)
        usage = _dim_line(_t("busy.usage"))
        if not arg or arg == "status":
            mode = self.busy_input_mode if self.busy_input_mode in _BUSY_MODES else "interrupt"
            return _cp(_accent_line(_t("busy.status", mode=self.busy_input_mode)),
                       _dim_line(_t("busy.enter_while_busy", behavior=_t(f"busy.short_{mode}"))), usage)
        if arg not in _BUSY_MODES:
            return _cp(_dim_line(_t("shared.unknown_argument", arg=arg)), usage)
        self.busy_input_mode = arg
        _persist_display_choice("display.busy_input_mode", arg, _t("busy.label"), _t(f"busy.long_{arg}"))

    def _handle_indicator_command(self, cmd: str):
        """Handle /indicator [status|kaomoji|emoji|unicode|ascii] — pick the TUI busy-indicator style.
        Persists to ``display.tui_status_indicator`` (the key the TUI reads) for its next render."""
        from hermes_constants import DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES
        current = (self.config.get("display") or {}).get("tui_status_indicator", DEFAULT_INDICATOR_STYLE)
        arg = _command_arg(cmd, lower=True)
        usage = _dim_line(_t("indicator.usage", styles="|".join(INDICATOR_STYLES)))
        if not arg or arg == "status":
            return _cp(_accent_line(_t("indicator.status", style=current)), usage)
        if arg not in INDICATOR_STYLES:
            return _cp(_dim_line(_t("indicator.unknown_style", arg=arg)), usage)
        self.config.setdefault("display", {})["tui_status_indicator"] = arg
        _persist_display_choice("display.tui_status_indicator", arg, _t("indicator.label"), _t("indicator.note"))

    def _handle_fast_command(self, cmd: str):
        """Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode).
        Session-scoped by default; ``--global`` persists agent.service_tier to config.yaml
        (parity with /model and /reasoning)."""
        if not self._fast_command_available():
            return _cp(f"  {_t('fast.not_supported')}")
        # Determine the branding for the current model
        model = getattr(getattr(self, "agent", None), "model", None) or getattr(self, "model", None)
        anthropic = _probe("hermes_cli.models", "_is_anthropic_fast_model", None, model)
        feature_name = _t("fast.feature_generic" if anthropic is None
                          else "fast.feature_anthropic" if anthropic else "fast.feature_openai")
        raw = _command_arg(cmd)
        usage = _dim_line(_t("fast.usage"))
        if not raw or raw.lower() == "status":
            from agent.fast_mode import service_tier_word
            status = service_tier_word(self.service_tier)
            return _cp(_accent_line(_t("fast.status", feature=feature_name, status=status)), usage)
        arg, explicit_global = _split_scope_flags(raw)
        if arg not in _FAST_TIERS:
            return _cp(_dim_line(_t("shared.unknown_argument", arg=arg)), usage)
        if arg == "ultrafast":
            if not _probe("hermes_cli.models", "model_supports_ultrafast", False, model):
                return _cp(_dim_line(_t("fast.ultrafast_not_supported", model=model or "?")), usage)
            feature_name = _t("fast.feature_ultrafast")
        self.service_tier, saved_value = _FAST_TIERS[arg]
        _retire_agent(self)  # Force agent re-init with new service-tier config
        saved = explicit_global and _save("agent.service_tier", saved_value)
        outcome = _scope_outcome(explicit_global, saved)
        _cp(_accent_line(_t("fast.set_to", feature=feature_name, value=saved_value.upper(), scope=outcome)))

    # ---- /debug, /update, /voice, /wake ---------------------------------------------------
    def _handle_debug_command(self, cmd_original: str = ""):
        """Handle /debug [nous|local] — upload debug report + logs and print share URLs.
        Default: public paste service; ``nous``: Nous-internal (staff-only); ``local``: render to
        stdout, no upload. ``local`` wins if both are given (never touches the network)."""
        from hermes_cli.debug import run_debug_share
        from types import SimpleNamespace
        words = {w.lower() for w in cmd_original.split()[1:]}
        local = "local" in words
        # Typing /debug is the upload consent (yes=True); input() would hang in prompt_toolkit anyway.
        run_debug_share(SimpleNamespace(
            lines=200, expire=7, local=local, nous="nous" in words and not local, yes=True))

    def _handle_update_command(self) -> bool:
        """Handle /update — exit the session and relaunch as ``hermes update``. Returns True when
        confirmed (the caller exits the app; the relaunch runs on the main thread after
        prompt_toolkit restores terminal modes), False when cancelled."""
        from hermes_cli.config import is_managed, format_managed_message
        if is_managed():
            print(f"  ✗ {format_managed_message(_t('update.managed_action'))}")
            return False
        # prompt_toolkit-native modal: renders above the composer, no raw input() races.
        choices = [("once", _t("update.choice_update"), _t("update.choice_update_desc")),
                   ("cancel", _t("update.choice_cancel"), _t("update.choice_cancel_desc"))]
        raw = self._prompt_text_input_modal(
            title=_t("update.title"), detail=_t("update.detail"), choices=choices)
        if raw is None or self._normalize_slash_confirm_choice(raw, choices) != "once":
            print(f"  {_t('update.cancelled')}")
            return False
        _say_block(f"  {_t('update.launching')}")
        # run() execs this on the main thread after prompt_toolkit restores terminal modes;
        # relaunching from this daemon thread would skip cleanup (POSIX) / only end the thread (Windows).
        self._pending_relaunch = ["update"]
        return True

    def _handle_voice_command(self, command: str):
        """Handle /voice [on|off|tts|status] command."""
        subcommand = _command_arg(command, lower=True) or ("off" if self._voice_mode else "on")
        actions = {"on": self._enable_voice_mode, "off": self._disable_voice_mode,
                   "tts": self._toggle_voice_tts, "status": self._show_voice_status}
        if subcommand in actions:
            actions[subcommand]()
        else:
            _cp(_t("voice.unknown_subcommand", subcommand=subcommand), _t("voice.usage"))

    def _handle_wake_command(self, command: str):
        """Handle /wake [on|off|status] — the 'Hey Hermes' hotword listener. The toggle IS the
        config: on/off also writes ``wake_word.enabled`` so the choice persists; startup
        auto-arm only reads it."""
        subcommand = _command_arg(command, lower=True) or (
            "off" if getattr(self, "_wake_word_active", False) else "on")  # bare /wake toggles
        if subcommand == "on":
            if self._start_wake_word_listener(announce=True):
                self._persist_wake_word_enabled(True)
        elif subcommand == "off":
            self._stop_wake_word_listener(announce=True)
            self._persist_wake_word_enabled(False)
        elif subcommand == "status":
            self._show_wake_word_status()
        else:
            _cp(_t("wake.unknown_subcommand", subcommand=subcommand), _t("wake.usage"))

    def _persist_wake_word_enabled(self, enabled: bool):
        """Save ``wake_word.enabled`` so the /wake toggle sticks for future sessions."""
        persisted = _probe("tools.wake_word", "load_wake_word_config", None)
        if isinstance(persisted, dict) and bool(persisted.get("enabled")) == enabled:
            return  # already persisted — don't rewrite config or re-announce
        if _save("wake_word.enabled", enabled):
            _cp(_dim(_t("wake.enabled_in_config" if enabled else "wake.disabled_in_config")))
