{"repo":"NousResearch/hermes-agent","public_id":"21348","display_label":"CLI slash commands freezing and blocking issues","summary":"Multiple bugs and fixes related to slash commands (/new, /clear, /reset, /reload-mcp) causing terminal freezes, deadlocks, and blocking behavior, particularly around threading, event loop scheduling, and prompt handling across platforms.","subsystem":"hermes_cli","counts":{"members":47,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":20,"merged_prs":5,"attempt_prs":20},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":23454},"resolution":{"state":"partially_resolved","by":"pr-23907,pr-31704,pr-33241"},"freshness":{"state":"verified","last_verified_at":"2026-05-27T12:57:02+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.04506938020351524},"fix_edges":[{"issue_number":22970,"pr_number":23454,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":23155,"pr_number":23907,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":23297,"pr_number":23454,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":23694,"pr_number":23907,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":23853,"pr_number":23907,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":30768,"pr_number":31704,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":30768,"pr_number":32013,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":33165,"pr_number":33241,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":21348,"title":"RuntimeWarning: coroutine 'run_in_terminal.<locals>.run' was never awaited during /reload-mcp","url":null,"state":"closed"},{"kind":"issue","number":22241,"title":"/reload-mcp confirmation prompt is broken — async coroutine never awaited","url":null,"state":"closed"},{"kind":"issue","number":22958,"title":"[Bug]: /clear, /new, /reset, /undo confirmation prompt cannot be answered — keystrokes leak into chat composer","url":null,"state":"closed"},{"kind":"issue","number":22970,"title":"fix(cli): RuntimeWarning in /new, /clear, /undo, /reload-mcp — coroutine was never awaited","url":null,"state":"closed"},{"kind":"issue","number":23009,"title":"bug: /reload-mcp triggers RuntimeWarning from unawaited run_in_terminal coroutine","url":null,"state":"closed"},{"kind":"issue","number":23185,"title":"run_in_terminal() coroutines not awaited — silent message loss (output) + broken input on WSL","url":null,"state":"closed"},{"kind":"issue","number":23297,"title":"[Bug]: RuntimeWarning “run_in_terminal... was never awaited” when confirming /clear or /new in CLI","url":null,"state":"closed"},{"kind":"issue","number":23567,"title":"[Bug]: When using the Hermes Agent in WSL, running the /clear command will cause the program to freeze.","url":null,"state":"closed"},{"kind":"issue","number":23694,"title":"[Bug]: Terminal hang when prompting for confirmation on destructive slash commands (/new, /clear) (windows beta)","url":null,"state":"closed"},{"kind":"issue","number":23853,"title":"/reload-mcp deadlock: _prompt_text_input input() blocks daemon thread in raw mode","url":null,"state":"closed"},{"kind":"issue","number":23920,"title":"[Bug]: '/new' confirmation freezes session","url":null,"state":"closed"},{"kind":"issue","number":24343,"title":"/new and /clear hang on confirmation prompt — daemon-thread input() competes with prompt_toolkit stdin","url":null,"state":"closed"},{"kind":"issue","number":24701,"title":"CLI /new can stall at destructive confirmation and repeat prior task","url":null,"state":"closed"},{"kind":"issue","number":30768,"title":"Bug: /reset and /new slash commands freeze on Windows — routes to interactive CLI menu","url":null,"state":"closed"},{"kind":"issue","number":32207,"title":"[Bug]: /clear command freezes or bugs the terminal","url":null,"state":"closed"},{"kind":"issue","number":32383,"title":"fix(cli): /new, /clear, /reset freeze permanently on Linux after fae815a (PR #30773)","url":null,"state":"closed"},{"kind":"issue","number":33165,"title":"[Bug]: macOS：When Hermes is connected to DeepSeek, using the /new command causes it to freeze completely.","url":null,"state":"closed"},{"kind":"issue","number":33961,"title":"[Bug]:  /new, /clear, and /reset commands freeze the terminal session","url":null,"state":"closed"},{"kind":"issue","number":34140,"title":"[Bug]:  CLI freezes after using /new command (WSL)","url":null,"state":"closed"},{"kind":"issue","number":37230,"title":"[Bug]: /new and /clear hang/freeze the Hermes CLI process on Windows 11 (Git Bash MSYS2)","url":null,"state":"closed"},{"kind":"issue","number":39418,"title":"/reload-mcp freezes the CLI terminal when triggered by user — session becomes unresponsive","url":null,"state":"open"},{"kind":"issue","number":66258,"title":"hermes-mcp-reload-bug-issue","url":null,"state":"open"},{"kind":"pr","number":22972,"title":"fix(cli): prevent RuntimeWarning in _prompt_text_input from background thread","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":22989,"title":"fix(cli): use prompt_toolkit.prompt() for TUI confirmation prompts (Fixes #22958)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22958","number":22958}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":23078,"title":"fix(cli): add main-thread guard to _prompt_text_input — prevent RuntimeWarning (#22970)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22972","number":22972}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":23257,"title":"fix(cli): avoid run_in_terminal from worker threads","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23317,"title":"fix(cli): suppress RuntimeWarning from unawaited run_in_terminal in prompt_toolkit 3.0.52+","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23454","number":23454},{"kind":"pr","value":"#23454","number":23454}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":23348,"title":"fix(cli): use prompt_toolkit prompt for confirmation input","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22989","number":22989},{"kind":"pr","value":"#22958","number":22958}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":23454,"title":"fix(cli): drive _prompt_text_input directly when off main thread (#23185)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_prompt_text_input_thread_safety.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -6008,7 +6008,17 @@ def _pick():\n         return result[0]\n \n     def _prompt_text_input(self, prompt_text: str) -> str | None:\n-        \"\"\"Prompt for free-text input safely inside or outside prompt_toolkit.\"\"\"\n+        \"\"\"Prompt for free-text input safely inside or outside prompt_toolkit.\n+\n+        Mirrors the thread-aware guard in ``_run_curses_picker``: ``run_in_terminal``\n+        returns a coroutine that must be awaited by the prompt_toolkit event loop,\n+        which only exists on the main thread.  Slash commands are dispatched from\n+        the ``process_loop`` daemon thread (see issue #23185), so calling\n+        ``run_in_terminal`` from there orphans the coroutine — ``_ask`` never runs,\n+        and user keystrokes leak into the composer instead.  Fall back to a direct\n+        ``input()`` when we're off the main thread.\n+        \"\"\"\n+        import threading\n         result = [None]\n \n         def _ask():","@@ -6017,13 +6027,23 @@ def _ask():\n             except (KeyboardInterrupt, EOFError):\n                 pass\n \n-        if self._app:\n+        in_main_thread = threading.current_thread() is threading.main_thread()\n+\n+        if self._app and in_main_thread:\n             from prompt_toolkit.application import run_in_terminal\n             was_visible = self._status_bar_visible\n             self._status_bar_visible = False\n             self._app.invalidate()\n             try:\n                 run_in_terminal(_ask)\n+            except Exception:\n+                # WSL / Warp / certain terminal emulators silently drop the\n+                # scheduled coroutine.  Fall back to a direct input() so the\n+                # user's keystrokes don't leak into the agent buffer.\n+                try:\n+                    _ask()\n+                except Exception:\n+                    pass\n             finally:\n                 self._status_bar_visible = was_visible\n                 self._app.invalidate()"]},{"file":"tests/cli/test_prompt_text_input_thread_safety.py","hunks":["@@ -0,0 +1,109 @@\n+\"\"\"Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.\n+\n+Slash commands (``/clear``, ``/new``, ``/undo``, ``/reload-mcp``) are dispatched\n+from the ``process_loop`` daemon thread.  ``prompt_toolkit.run_in_terminal``\n+returns a coroutine that only the main-thread event loop can drive; calling it\n+from a daemon thread orphans the coroutine, ``_ask`` never runs, and user\n+keystrokes leak into the composer instead of the confirmation prompt\n+(see issue #23185).\n+\n+The fix mirrors ``_run_curses_picker``: when off the main thread, fall back to\n+a direct ``input()`` call so the prompt actually renders and consumes\n+keystrokes.\n+\"\"\"\n+\n+import threading\n+from unittest.mock import MagicMock, patch\n+\n+\n+def _make_cli():\n+    \"\"\"Minimal HermesCLI shell exposing ``_prompt_text_input``.\"\"\"\n+    import cli as cli_mod\n+\n+    obj = object.__new__(cli_mod.HermesCLI)\n+    obj._app = MagicMock()\n+    obj._status_bar_visible = True\n+    return obj\n+\n+\n+class TestPromptTextInputThreadSafety:\n+    def test_main_thread_uses_run_in_terminal(self):\n+        \"\"\"On the main thread with an active app, route through run_in_terminal.\"\"\"\n+        cli = _make_cli()\n+\n+        with patch(\"prompt_toolkit.application.run_in_terminal\") as mock_rit, \\\n+             patch(\"builtins.input\", return_value=\"2\"):\n+            result = cli._prompt_text_input(\"Choice: \")\n+\n+        # run_in_terminal was invoked; the _ask closure passed to it would\n+        # call input() when driven by the event loop.  We assert dispatch path,\n+        # not the orphaned-coroutine result.\n+        assert mock_rit.called\n+\n+    def test_background_thread_falls_back_to_direct_input(self):\n+        \"\"\"On a daemon thread, skip run_in_terminal and call input() directly.\n+\n+        This is the bug from issue #23185: process_loop dispatches slash\n+        commands on a daemon thread, so run_in_terminal's coroutine is\n+        orphaned.  The fallback must drive input() itself so user keystrokes\n+        don't leak into the agent buffer.\n+        \"\"\"\n+        cli = _make_cli()\n+        captured = {}\n+\n+        def fake_input(prompt):\n+            captured[\"prompt\"] = prompt\n+            return \"1\"\n+\n+        result_holder = {}\n+\n+        def run_on_daemon():\n+            with patch(\"prompt_toolkit.application.run_in_terminal\") as mock_rit, \\\n+                 patch(\"builtins.input\", side_effect=fake_input):\n+                result_holder[\"value\"] = cli._prompt_text_input(\"Choice [1/2/3]: \")\n+                result_holder[\"rit_called\"] = mock_rit.called\n+\n+        t = threading.Thread(target=run_on_daemon, daemon=True)\n+        t.start()\n+        t.join(timeout=2.0)\n+        assert not t.is_alive(), \"daemon thread hung — input() was not driven\"\n+\n+        # run_in_terminal was bypassed entirely on the background thread.\n+        assert result_holder[\"rit_called\"] is False\n+        # input() was invoked with the prompt and its return value was captured.\n+        assert captured.get(\"prompt\") == \"Choice [1/2/3]: \"\n+        assert result_holder[\"value\"] == \"1\"\n+\n+    def test_no_app_uses_direct_input(self):\n+        \"\"\"Without an active prompt_toolkit app, always call input() directly.\"\"\"\n+        cli = _make_cli()\n+        cli._app = None\n+\n+        with patch(\"builtins.input\", return_value=\"cancel\") as mock_input:\n+            result = cli._prompt_text_input(\"Choice: \")\n+\n+        assert mock_input.called\n+        assert result == \"cancel\"\n+\n+    def test_run_in_terminal_exception_falls_back(self):\n+        \"\"\"If run_in_terminal raises (WSL / Warp edge cases), fall back to input().\"\"\"\n+        cli = _make_cli()\n+\n+        with patch(\n+            \"prompt_toolkit.application.run_in_terminal\",\n+            side_effect=RuntimeError(\"event loop dropped the coroutine\"),\n+        ), patch(\"builtins.input\", return_value=\"3\") as mock_input:\n+            result = cli._prompt_text_input(\"Choice: \")\n+\n+        assert mock_input.called\n+        assert result == \"3\"\n+\n+    def test_eof_returns_none(self):\n+        \"\"\"EOFError from input() yields None, not an unhandled exception.\"\"\"\n+        cli = _make_cli()\n+        cli._app = None\n+\n+        with patch(\"builtins.input\", side_effect=EOFError()):\n+            result = cli._prompt_text_input(\"Choice: \")\n+\n+        assert result is None\n"]}]},{"kind":"pr","number":23571,"title":"fix: use TUI modal for slash confirmations","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"054f56857","number":0},{"kind":"pr","value":"#23907","number":23907},{"kind":"pr","value":"#23694","number":23694},{"kind":"pr","value":"#23853","number":23853}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":23749,"title":"fix(cli): schedule destructive prompts on app loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23907","number":23907},{"kind":"pr","value":"#23571","number":23571},{"kind":"pr","value":"#23694","number":23694}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":23886,"title":"fix(cli): route destructive slash confirmations through main thread e…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23907","number":23907},{"kind":"pr","value":"#23571","number":23571},{"kind":"pr","value":"#23694","number":23694},{"kind":"pr","value":"#23853","number":23853}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":23907,"title":"fix(cli): use prompt_toolkit modal for destructive slash confirmations","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","scripts/release.py","tests/cli/test_destructive_slash_confirm.py","tests/cli/test_prompt_text_input_thread_safety.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -2558,6 +2558,8 @@ def __init__(\n         self._approval_state = None\n         self._approval_deadline = 0\n         self._approval_lock = threading.Lock()\n+        self._slash_confirm_state = None\n+        self._slash_confirm_deadline = 0\n         self._model_picker_state = None\n         self._secret_state = None\n         self._secret_deadline = 0","@@ -3715,7 +3717,7 @@ def _open_external_editor(self, buffer=None) -> bool:\n         if self._command_running:\n             _cprint(f\"{_DIM}Wait for the current command to finish before opening the editor.{_RST}\")\n             return False\n-        if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state:\n+        if self._sudo_state or self._secret_state or self._approval_state or getattr(self, \"_slash_confirm_state\", None) or self._clarify_state:\n             _cprint(f\"{_DIM}Finish the active prompt before opening the editor.{_RST}\")\n             return False\n         target_buffer = buffer or getattr(app, \"current_buffer\", None)","@@ -6059,6 +6061,194 @@ def _ask():\n             _ask()\n         return result[0]\n \n+    def _prompt_text_input_modal(\n+        self,\n+        *,\n+        title: str,\n+        detail: str,\n+        choices: list[tuple[str, str, str]],\n+        timeout: float = 120,\n+    ) -> str | None:\n+        \"\"\"Prompt through the prompt_toolkit composer instead of raw input().\n+\n+        This is for CLI slash-command confirmations.  The old raw input() path\n+        fought prompt_toolkit's active stdin ownership: in some terminals the\n+        prompt appeared above the TUI, choices were redrawn later, and Enter\n+        could be interpreted as EOF/exit.  A first-class modal state keeps the\n+        choices visible and lets the normal Enter key binding submit the typed\n+        or highlighted choice.\n+        \"\"\"\n+        import time as _time\n+\n+        if not choices:\n+            return None\n+\n+        # If prompt_toolkit is not running (unit tests / non-interactive calls),\n+        # keep the simple stdin fallback.\n+        if not getattr(self, \"_app\", None):\n+            return self._prompt_text_input(\"Choice [1/2/3]: \")\n+\n+        response_queue = queue.Queue()\n+        self._capture_modal_input_snapshot()\n+        self._slash_confirm_state = {\n+            \"title\": title,\n+            \"detail\": detail,\n+            \"choices\": choices,\n+            \"selected\": 0,\n+            \"response_queue\": response_queue,\n+        }\n+        self._slash_confirm_deadline = _time.monotonic() + timeout\n+        self._invalidate()\n+\n+        _last_countdown_refresh = _time.monotonic()\n+        try:\n+            while True:\n+                try:\n+                    result = response_queue.get(timeout=1)\n+                    self._slash_confirm_state = None\n+                    self._slash_confirm_deadline = 0\n+                    self._restore_modal_input_snapshot()\n+                    self._invalidate()\n+                    return result\n+                except queue.Empty:\n+                    remaining = self._slash_confirm_deadline - _time.monotonic()\n+                    if remaining <= 0:\n+                        break\n+                    now = _time.monotonic()\n+                    if now - _last_countdown_refresh >= 5.0:\n+                        _last_countdown_refresh = now\n+                        self._invalidate()\n+        finally:\n+            if self._slash_confirm_state is not None:\n+                self._slash_confirm_state = None\n+                self._slash_confirm_deadline = 0\n+                self._restore_modal_input_snapshot()\n+                self._invalidate()\n+        return None\n+\n+    def _submit_slash_confirm_response(self, value: str | None) -> None:\n+        state = self._slash_confirm_state\n+        if not state:\n+            return\n+        state[\"response_queue\"].put(value)\n+        self._slash_confirm_state = None\n+        self._slash_confirm_deadline = 0\n+        self._invalidate()\n+\n+    def _normalize_slash_confirm_choice(\n+        self,\n+        raw: str | None,\n+        choices: list[tuple[str, str, str]],\n+    ) -> str | None:\n+        if raw is None:\n+            return None\n+        choice_raw = raw.strip().lower()\n+        if not choice_raw:\n+            return None\n+        aliases = {\n+            \"1\": \"once\",\n+            \"once\": \"once\",\n+            \"approve\": \"once\",\n+            \"yes\": \"once\",\n+            \"y\": \"once\",\n+            \"ok\": \"once\",\n+            \"2\": \"always\",\n+            \"always\": \"always\",\n+            \"remember\": \"always\",\n+            \"3\": \"cancel\",\n+            \"cancel\": \"cancel\",\n+            \"nevermind\": \"cancel\",\n+            \"no\": \"cancel\",\n+            \"n\": \"cancel\",\n+        }\n+        allowed = {choice[0] for choice in choices}\n+        normalized = aliases.get(choice_raw)\n+        if normalized in allowed:\n+            return normalized\n+        if choice_raw in allowed:\n+            return choice_raw\n+        return None\n+\n+    def _get_slash_confirm_display_fragments(self):\n+        \"\"\"Render the /new-/clear-style confirmation panel.\"\"\"\n+        state = self._slash_confirm_state\n+        if not state:\n+            return []\n+\n+        title = state.get(\"title\") or \"Confirm action\"\n+        detail = state.get(\"detail\") or \"\"\n+        choices = state.get(\"choices\") or []\n+        selected = state.get(\"selected\", 0)\n+\n+        def _panel_box_width(title_text: str, content_lines: list[str], min_width: int = 56, max_width: int = 86) -> int:\n+            term_cols = shutil.get_terminal_size((100, 20)).columns\n+            longest = max([len(title_text)] + [len(line) for line in content_lines] + [min_width - 4])\n+            inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6))\n+            return inner + 2\n+\n+        def _wrap_panel_text(text: str, width: int, subsequent_indent: str = \"\") -> list[str]:\n+            wrapped = textwrap.wrap(\n+                text,\n+                width=max(8, width),\n+                replace_whitespace=False,\n+                drop_whitespace=False,\n+                subsequent_indent=subsequent_indent,\n+            )\n+            return wrapped or [\"\"]\n+\n+        def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None:\n+            inner_width = max(0, box_width - 2)\n+            lines.append((border_style, \"│ \"))\n+            lines.append((content_style, text.ljust(inner_width)))\n+            lines.append((border_style, \" │\\n\"))\n+\n+        def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:\n+            lines.append((border_style, \"│\" + (\" \" * box_width) + \"│\\n\"))\n+\n+        preview_lines = []\n+        for line in detail.splitlines():\n+            preview_lines.extend(_wrap_panel_text(line, 72))\n+        for idx, (_value, label, desc) in enumerate(choices):\n+            marker = \"❯\" if idx == selected else \" \"\n+            preview_lines.extend(_wrap_panel_text(f\"{marker} [{idx + 1}] {label} — {desc}\", 72, subsequent_indent=\"    \"))\n+        preview_lines.append(\"Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.\")\n+\n+        box_width = _panel_box_width(title, preview_lines)\n+        inner_text_width = max(8, box_width - 2)\n+        detail_wrapped = []\n+        for line in detail.splitlines():\n+            detail_wrapped.extend(_wrap_panel_text(line, inner_text_width))\n+        choice_wrapped: list[tuple[int, str]] = []\n+        for idx, (_value, label, desc) in enumerate(choices):\n+            marker = \"❯\" if idx == selected else \" \"\n+            for wrapped in _wrap_panel_text(f\"{marker} [{idx + 1}] {label} — {desc}\", inner_text_width, subsequent_indent=\"    \"):\n+                choice_wrapped.append((idx, wrapped))\n+\n+        term_rows = shutil.get_terminal_size((100, 24)).lines\n+        reserved_below = 6\n+        chrome_full = 6\n+        available = max(0, term_rows - reserved_below)\n+        max_detail_rows = max(1, available - chrome_full - len(choice_wrapped))\n+        max_detail_rows = min(max_detail_rows, 8)\n+        if len(detail_wrapped) > max_detail_rows:\n+            keep = max(1, max_detail_rows - 1)\n+            detail_wrapped = detail_wrapped[:keep] + [\"… (detail truncated)\"]\n+\n+        lines = []\n+        lines.append(('class:approval-border', '╭' + ('─' * box_width) + '╮\\n'))\n+        _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width)\n+        _append_blank_panel_line(lines, 'class:approval-border', box_width)\n+        for wrapped in detail_wrapped:\n+            _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width)\n+        _append_blank_panel_line(lines, 'class:approval-border', box_width)\n+        for idx, wrapped in choice_wrapped:\n+            style = 'class:approval-selected' if idx == selected else 'class:approval-choice'\n+            _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)\n+        _append_blank_panel_line(lines, 'class:approval-border', box_width)\n+        _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', 'Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.', box_width)\n+        lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\\n'))\n+        return lines\n+\n     def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:\n         \"\"\"Open prompt_toolkit-native /model picker modal.\"\"\"\n         self._capture_modal_input_snapshot()","@@ -8577,30 +8767,24 @@ def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]\n         if not confirm_required:\n             return \"once\"\n \n-        # Render warning + prompt — single-line composer prompt, mirrors\n-        # ``_confirm_and_reload_mcp``.\n-        print()\n-        print(f\"⚠️  /{command} — destroys conversation state\")\n-        print()\n-        for line in detail.splitlines():\n-            print(f\"  {line}\")\n-        print()\n-        print(\"  [1] Approve Once   — proceed this time only\")\n-        print(\"  [2] Always Approve — proceed and silence this prompt permanently\")\n-        print(\"  [3] Cancel         — keep current conversation\")\n-        print()\n-        raw = self._prompt_text_input(\"Choice [1/2/3]: \")\n+        # Render a prompt_toolkit-native confirmation panel.  This keeps option\n+        # labels visible above the composer and avoids raw input()/EOF races with\n+        # the running TUI.\n+        choices = [\n+            (\"once\", \"Approve Once\", \"proceed this time only\"),\n+            (\"always\", \"Always Approve\", \"proceed and silence this prompt permanently\"),\n+            (\"cancel\", \"Cancel\", \"keep current conversation\"),\n+        ]\n+        raw = self._prompt_text_input_modal(\n+            title=f\"⚠️  /{command} — destroys conversation state\",\n+            detail=detail,\n+            choices=choices,\n+        )\n         if raw is None:\n             print(f\"🟡 /{command} cancelled (no input).\")\n             return None\n-        choice_raw = raw.strip().lower()\n-        if choice_raw in (\"1\", \"once\", \"approve\", \"yes\", \"y\", \"ok\"):\n-            choice = \"once\"\n-        elif choice_raw in (\"2\", \"always\", \"remember\"):\n-            choice = \"always\"\n-        elif choice_raw in (\"3\", \"cancel\", \"nevermind\", \"no\", \"n\", \"\"):\n-            choice = \"cancel\"\n-        else:\n+        choice = self._normalize_slash_confirm_choice(raw, choices)\n+        if choice is None:\n             print(f\"🟡 Unrecognized choice '{raw}'. /{command} cancelled.\")\n             return None\n ","@@ -8645,32 +8829,28 @@ def _confirm_and_reload_mcp(self, cmd_original: str = \"\") -> None:\n                 self._reload_mcp()\n             return\n \n-        # Render warning + prompt.  Use a single-line prompt so the user\n-        # sees the warning as output and types a response into the composer.\n-        print()\n-        print(\"⚠️  /reload-mcp — Prompt cache invalidation warning\")\n-        print()\n-        print(\"  Reloading MCP servers rebuilds the tool set for this session and\")\n-        print(\"  invalidates the provider prompt cache.  The next message will\")\n-        print(\"  re-send full input tokens (can be expensive on long-context or\")\n-        print(\"  high-reasoning models).\")\n-        print()\n-        print(\"  [1] Approve Once   — reload now\")\n-        print(\"  [2] Always Approve — reload now and silence this prompt permanently\")\n-        print(\"  [3] Cancel         — leave MCP tools unchanged\")\n-        print()\n-        raw = self._prompt_text_input(\"Choice [1/2/3]: \")\n+        # Render warning + prompt.  Use the same prompt_toolkit-native composer\n+        # modal as destructive slash confirmations so choices stay visible.\n+        choices = [\n+            (\"once\", \"Approve Once\", \"reload now\"),\n+            (\"always\", \"Always Approve\", \"reload now and silence this prompt permanently\"),\n+            (\"cancel\", \"Cancel\", \"leave MCP tools unchanged\"),\n+        ]\n+        raw = self._prompt_text_input_modal(\n+            title=\"⚠️  /reload-mcp — Prompt cache invalidation warning\",\n+            detail=(\n+                \"Reloading MCP servers rebuilds the tool set for this session and\\n\"\n+                \"invalidates the provider prompt cache. The next message will\\n\"\n+                \"re-send full input tokens (can be expensive on long-context or\\n\"\n+                \"high-reasoning models).\"\n+            ),\n+            choices=choices,\n+        )\n         if raw is None:\n             print(\"🟡 /reload-mcp cancelled (no input).\")\n             return\n-        choice_raw = raw.strip().lower()\n-        if choice_raw in (\"1\", \"once\", \"approve\", \"yes\", \"y\", \"ok\"):\n-            choice = \"once\"\n-        elif choice_raw in (\"2\", \"always\", \"remember\"):\n-            choice = \"always\"\n-        elif choice_raw in (\"3\", \"cancel\", \"nevermind\", \"no\", \"n\", \"\"):\n-            choice = \"cancel\"\n-        else:\n+        choice = self._normalize_slash_confirm_choice(raw, choices)\n+        if choice is None:\n             print(f\"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.\")\n             return\n ","@@ -10594,6 +10774,8 @@ def _state_fragment(style: str, icon: str, extra: str = \"\"):\n             return _state_fragment(\"class:sudo-prompt\", \"🔑\")\n         if self._approval_state:\n             return _state_fragment(\"class:prompt-working\", \"⚠\")\n+        if getattr(self, \"_slash_confirm_state\", None):\n+            return _state_fragment(\"class:prompt-working\", \"⚠\")\n         if self._clarify_freetext:\n             return _state_fragment(\"class:clarify-selected\", \"✎\")\n         if self._clarify_state:","@@ -10660,6 +10842,7 @@ def _build_tui_layout_children(\n         sudo_widget,\n         secret_widget,\n         approval_widget,\n+        slash_confirm_widget=None,\n         clarify_widget,\n         model_picker_widget=None,\n         spinner_widget=None,","@@ -10684,6 +10867,7 @@ def _build_tui_layout_children(\n                 sudo_widget,\n                 secret_widget,\n                 approval_widget,\n+                slash_confirm_widget,\n                 clarify_widget,\n                 model_picker_widget,\n                 spinner_widget,","@@ -10846,6 +11030,13 @@ def run(self):\n         self._approval_deadline = 0\n         self._approval_lock = threading.Lock()  # serialize concurrent approval prompts (delegation race fix)\n \n+        # Destructive slash-command confirmation state (/new, /clear, /undo).\n+        # These prompts are answered through the prompt_toolkit composer, not\n+        # raw input(), so the option labels stay visible and Enter does not EOF\n+        # the whole app.\n+        self._slash_confirm_state = None\n+        self._slash_confirm_deadline = 0\n+\n         # Slash command loading state\n         self._command_running = False\n         self._command_status = \"\"","@@ -10937,6 +11128,20 @@ def handle_enter(event):\n                 event.app.invalidate()\n                 return\n \n+            # --- Slash-command confirmation: submit typed or highlighted choice ---\n+            if self._slash_confirm_state:\n+                text = event.app.current_buffer.text.strip()\n+                choices = self._slash_confirm_state.get(\"choices\") or []\n+                choice = self._normalize_slash_confirm_choice(text, choices) if text else None\n+                if choice is None:\n+                    selected = self._slash_confirm_state.get(\"selected\", 0)\n+                    if 0 <= selected < len(choices):\n+                        choice = choices[selected][0]\n+                self._submit_slash_confirm_response(choice or \"cancel\")\n+                event.app.current_buffer.reset()\n+                event.app.invalidate()\n+                return\n+\n             # --- /model picker modal ---\n             if self._model_picker_state:\n                 try:","@@ -11197,6 +11402,20 @@ def approval_down(event):\n                 self._approval_state[\"selected\"] = min(max_idx, self._approval_state[\"selected\"] + 1)\n                 event.app.invalidate()\n \n+        # --- Slash-command confirmation: arrow-key navigation ---\n+        @kb.add('up', filter=Condition(lambda: bool(self._slash_confirm_state)))\n+        def slash_confirm_up(event):\n+            if self._slash_confirm_state:\n+                self._slash_confirm_state[\"selected\"] = max(0, self._slash_confirm_state.get(\"selected\", 0) - 1)\n+                event.app.invalidate()\n+\n+        @kb.add('down', filter=Condition(lambda: bool(self._slash_confirm_state)))\n+        def slash_confirm_down(event):\n+            if self._slash_confirm_state:\n+                max_idx = len(self._slash_confirm_state.get(\"choices\") or []) - 1\n+                self._slash_confirm_state[\"selected\"] = min(max_idx, self._slash_confirm_state.get(\"selected\", 0) + 1)\n+                event.app.invalidate()\n+\n         # --- /model picker: arrow-key navigation ---\n         @kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))\n         def model_picker_up(event):","@@ -11237,12 +11456,26 @@ def handler(event):\n             _idx = 9 if _num == 0 else _num - 1\n             kb.add(str(_num), filter=Condition(lambda: bool(self._approval_state)))(_make_approval_number_handler(_idx))\n \n+        # Number keys for quick slash-confirm selection (1-9, 0 for 10th item)\n+        def _make_slash_confirm_number_handler(idx):\n+            def handler(event):\n+                if self._slash_confirm_state and idx < len(self._slash_confirm_state.get(\"choices\") or []):\n+                    choice = self._slash_confirm_state[\"choices\"][idx][0]\n+                    self._submit_slash_confirm_response(choice)\n+                    event.app.current_buffer.reset()\n+                    event.app.invalidate()\n+            return handler\n+\n+        for _num in range(10):\n+            _idx = 9 if _num == 0 else _num - 1\n+            kb.add(str(_num), filter=Condition(lambda: bool(self._slash_confirm_state)))(_make_slash_confirm_number_handler(_idx))\n+\n         # --- History navigation: up/down browse history in normal input mode ---\n         # The TextArea is multiline, so by default up/down only move the cursor.\n         # Buffer.auto_up/auto_down handle both: cursor movement when multi-line,\n         # history browsing when on the first/last line (or single-line input).\n         _normal_input = Condition(\n-            lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state and not self._model_picker_state\n+            lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state\n         )\n \n         @kb.add('up', filter=_normal_input)","@@ -11318,6 +11551,13 @@ def handle_ctrl_c(event):\n                 event.app.invalidate()\n                 return\n \n+            # Cancel slash confirmation prompt\n+            if self._slash_confirm_state:\n+                self._submit_slash_confirm_response(\"cancel\")\n+                event.app.current_buffer.reset()\n+                event.app.invalidate()\n+                return\n+\n             # Cancel /model picker\n             if self._model_picker_state:\n                 self._close_model_picker()","@@ -11412,6 +11652,13 @@ def handle_ctrl_q(event):\n                 event.app.invalidate()\n                 return\n \n+            # Cancel slash confirmation prompt\n+            if self._slash_confirm_state:\n+                self._submit_slash_confirm_response(\"cancel\")\n+                event.app.current_buffer.reset()\n+                event.app.invalidate()\n+                return\n+\n             # Cancel /model picker\n             if self._model_picker_state:\n                 self._close_model_picker()","@@ -11460,7 +11707,7 @@ def handle_ctrl_d(event):\n                 event.app.exit()\n \n         _modal_prompt_active = Condition(\n-            lambda: bool(self._secret_state or self._sudo_state)\n+            lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state)\n         )\n \n         @kb.add('escape', filter=_modal_prompt_active, eager=True)","@@ -11476,6 +11723,11 @@ def handle_escape_modal(event):\n                 self._sudo_state = None\n                 event.app.invalidate()\n                 return\n+            if self._slash_confirm_state:\n+                self._submit_slash_confirm_response(\"cancel\")\n+                event.app.current_buffer.reset()\n+                event.app.invalidate()\n+                return\n \n         @kb.add('c-z')\n         def handle_ctrl_z(event):","@@ -11558,7 +11810,7 @@ def handle_voice_record(event):\n                 # Guard: don't START recording during agent run or interactive prompts\n                 if cli_ref._agent_running:\n                     return\n-                if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state:\n+                if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state or cli_ref._slash_confirm_state:\n                     return\n                 # Guard: don't start while a previous stop/transcribe cycle is\n                 # still running — recorder.stop() holds AudioRecorder._lock and","@@ -11845,6 +12097,8 @@ def _get_placeholder():\n                 return \"type secret (hidden), Enter to submit · ESC to skip\"\n             if cli_ref._approval_state:\n                 return \"\"\n+            if cli_ref._slash_confirm_state:\n+                return \"type 1/2/3, or use ↑/↓ then Enter\"\n             if cli_ref._clarify_freetext:\n                 return \"type your answer here and press Enter\"\n             if cli_ref._clarify_state:","@@ -11887,6 +12141,13 @@ def get_hint_text():\n                     ('class:clarify-countdown', f'  ({remaining}s)'),\n                 ]\n \n+            if cli_ref._slash_confirm_state:\n+                remaining = max(0, int(cli_ref._slash_confirm_deadline - time.monotonic()))\n+                return [\n+                    ('class:hint', '  type 1/2/3, or ↑/↓ to select, Enter to confirm'),\n+                    ('class:clarify-countdown', f'  ({remaining}s)'),\n+                ]\n+\n             if cli_ref._clarify_state:\n                 remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic()))\n                 countdown = f'  ({remaining}s)' if cli_ref._clarify_deadline else ''","@@ -11909,7 +12170,7 @@ def get_hint_text():\n             return []\n \n         def get_hint_height():\n-            if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running:\n+            if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._slash_confirm_state or cli_ref._clarify_state or cli_ref._command_running:\n                 return 1\n             # Keep a spacer while the agent runs on roomy terminals, but reclaim\n             # the row on narrow/mobile screens where every line matters.","@@ -12213,6 +12474,17 @@ def _get_approval_display():\n             filter=Condition(lambda: cli_ref._approval_state is not None),\n         )\n \n+        def _get_slash_confirm_display():\n+            return cli_ref._get_slash_confirm_display_fragments()\n+\n+        slash_confirm_widget = ConditionalContainer(\n+            Window(\n+                FormattedTextControl(_get_slash_confirm_display),\n+                wrap_lines=True,\n+            ),\n+            filter=Condition(lambda: cli_ref._slash_confirm_state is not None),\n+        )\n+\n         # --- /model picker: display widget ---\n         def _get_model_picker_display():\n             state = cli_ref._model_picker_state","@@ -12358,6 +12630,7 @@ def _get_voice_status():\n                     sudo_widget=sudo_widget,\n                     secret_widget=[redacted:secret-assignment]\n                     approval_widget=approval_widget,\n+                    slash_confirm_widget=slash_confirm_widget,\n                     clarify_widget=clarify_widget,\n                     model_picker_widget=model_picker_widget,\n                     spinner_widget=spinner_widget,"]},{"file":"scripts/release.py","hunks":["@@ -51,6 +51,7 @@\n     \"[redacted:email]\": \"teknium1\",\n     \"[redacted:email]\": \"thelumiereguy\",\n     \"[redacted:email]\": \"counterposition\",\n+    \"[redacted:email]\": \"zhengyn0001\",\n     \"[redacted:email]\": \"curiouscleo\",\n     \"[redacted:email]\": \"hrkzogw\",\n     \"[redacted:email]\": \"dandacompany\","]},{"file":"tests/cli/test_destructive_slash_confirm.py","hunks":["@@ -6,6 +6,7 @@\n \n from __future__ import annotations\n \n+import queue\n from types import SimpleNamespace\n from unittest.mock import patch\n ","@@ -17,10 +18,17 @@ def _bound(fn, instance):\n \n def _make_self(prompt_response):\n     \"\"\"Build a minimal stand-in 'self' for _confirm_destructive_slash.\"\"\"\n-    return SimpleNamespace(\n+    from cli import HermesCLI\n+\n+    self_ = SimpleNamespace(\n         _app=None,\n         _prompt_text_input=lambda _prompt: prompt_response,\n+        _prompt_text_input_modal=lambda **_kw: prompt_response,\n+    )\n+    self_._normalize_slash_confirm_choice = _bound(\n+        HermesCLI._normalize_slash_confirm_choice, self_,\n     )\n+    return self_\n \n \n def test_gate_off_returns_once_without_prompting():","@@ -117,7 +125,6 @@ def test_gate_on_choice_always_persists_and_returns_always():\n     self_ = _make_self(prompt_response=\"2\")\n \n     saves = []\n-\n     def _fake_save(key, value):\n         saves.append((key, value))\n         return True","@@ -150,3 +157,55 @@ def test_gate_default_true_when_config_missing():\n     # treated as on despite the config error.  If the gate had been off\n     # this would have returned 'once' without consulting the prompt.\n     assert result is None\n+\n+\n+def test_slash_confirm_modal_number_selection_submits_without_raw_input():\n+    \"\"\"Pressing 2 in the TUI modal should resolve to Always Approve directly.\"\"\"\n+    from cli import HermesCLI\n+\n+    q = queue.Queue()\n+    self_ = SimpleNamespace(\n+        _slash_confirm_state={\n+            \"choices\": [\n+                (\"once\", \"Approve Once\", \"proceed once\"),\n+                (\"always\", \"Always Approve\", \"persist opt-out\"),\n+                (\"cancel\", \"Cancel\", \"abort\"),\n+            ],\n+            \"selected\": 0,\n+            \"response_queue\": q,\n+        },\n+        _slash_confirm_deadline=123,\n+        _invalidate=lambda: None,\n+    )\n+\n+    _bound(HermesCLI._submit_slash_confirm_response, self_)(\"always\")\n+\n+    assert q.get_nowait() == \"always\"\n+    assert self_._slash_confirm_state is None\n+    assert self_._slash_confirm_deadline == 0\n+\n+\n+def test_slash_confirm_display_fragments_include_choice_mapping():\n+    \"\"\"The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'.\"\"\"\n+    from cli import HermesCLI\n+\n+    self_ = SimpleNamespace(\n+        _slash_confirm_state={\n+            \"title\": \"⚠️  /new — destroys conversation state\",\n+            \"detail\": \"This starts a fresh session.\",\n+            \"choices\": [\n+                (\"once\", \"Approve Once\", \"proceed once\"),\n+                (\"always\", \"Always Approve\", \"persist opt-out\"),\n+                (\"cancel\", \"Cancel\", \"abort\"),\n+            ],\n+            \"selected\": 1,\n+        },\n+    )\n+\n+    fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)()\n+    rendered = \"\".join(fragment for _style, fragment in fragments)\n+\n+    assert \"[1] Approve Once\" in rendered\n+    assert \"[2] Always Approve\" in rendered\n+    assert \"[3] Cancel\" in rendered\n+    assert \"Type 1/2/3\" in rendered"]},{"file":"tests/cli/test_prompt_text_input_thread_safety.py","hunks":["@@ -1,15 +1,9 @@\n \"\"\"Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.\n \n-Slash commands (``/clear``, ``/new``, ``/undo``, ``/reload-mcp``) are dispatched\n-from the ``process_loop`` daemon thread.  ``prompt_toolkit.run_in_terminal``\n-returns a coroutine that only the main-thread event loop can drive; calling it\n-from a daemon thread orphans the coroutine, ``_ask`` never runs, and user\n-keystrokes leak into the composer instead of the confirmation prompt\n-(see issue #23185).\n-\n-The fix mirrors ``_run_curses_picker``: when off the main thread, fall back to\n-a direct ``input()`` call so the prompt actually renders and consumes\n-keystrokes.\n+Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI.\n+The normal slash confirmations now use a prompt_toolkit-native modal, but\n+``_prompt_text_input`` remains as a fallback for non-interactive calls and edge\n+cases.\n \"\"\"\n \n import threading","@@ -17,7 +11,7 @@\n \n \n def _make_cli():\n-    \"\"\"Minimal HermesCLI shell exposing ``_prompt_text_input``.\"\"\"\n+    \"\"\"Minimal HermesCLI shell exposing prompt fallback helpers.\"\"\"\n     import cli as cli_mod\n \n     obj = object.__new__(cli_mod.HermesCLI)","@@ -33,7 +27,7 @@ def test_main_thread_uses_run_in_terminal(self):\n \n         with patch(\"prompt_toolkit.application.run_in_terminal\") as mock_rit, \\\n              patch(\"builtins.input\", return_value=\"2\"):\n-            result = cli._prompt_text_input(\"Choice: \")\n+            cli._prompt_text_input(\"Choice: \")\n \n         # run_in_terminal was invoked; the _ask closure passed to it would\n         # call input() when driven by the event loop.  We assert dispatch path,","@@ -43,10 +37,8 @@ def test_main_thread_uses_run_in_terminal(self):\n     def test_background_thread_falls_back_to_direct_input(self):\n         \"\"\"On a daemon thread, skip run_in_terminal and call input() directly.\n \n-        This is the bug from issue #23185: process_loop dispatches slash\n-        commands on a daemon thread, so run_in_terminal's coroutine is\n-        orphaned.  The fallback must drive input() itself so user keystrokes\n-        don't leak into the agent buffer.\n+        This preserves the fallback for any prompt that still runs off the main\n+        UI thread: run_in_terminal's coroutine would otherwise be orphaned.\n         \"\"\"\n         cli = _make_cli()\n         captured = {}\n"]}]},{"kind":"pr","number":24336,"title":"fix(cli): schedule /new /clear /undo confirm prompt on PT event loop from daemon thread","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22958","number":22958},{"kind":"pr","value":"#22970","number":22970}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":24722,"title":"fix(cli): defer destructive slash resets until active turns exit","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"458a94e42568b332e8794ca8fbb8c8e1279160a3","number":0},{"kind":"pr","value":"#24701","number":24701},{"kind":"pr","value":"#36833","number":36833}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":26617,"title":"fix(cli): await run_in_terminal futures to prevent RuntimeWarning","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c5f1f863acd49c92d9fbe92550115d51dd5181d0","number":0},{"kind":"commit","value":"365da2d2dfd92bd16f1993c8fefea05508282db8","number":0},{"kind":"pr","value":"#23454","number":23454},{"kind":"pr","value":"#23302","number":23302}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":30773,"title":"fix(cli): prevent /reset and /new freeze on Windows by falling back to stdin prompt","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32013","number":32013},{"kind":"pr","value":"#30768","number":30768}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":31704,"title":"fix(cli): add inline --yes/now skip for destructive slash commands (#30768)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_destructive_slash_confirm.py","tests/cli/test_destructive_slash_inline_skip_e2e.py","website/docs/reference/slash-commands.md"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -8101,6 +8101,7 @@ def process_command(self, command: str) -> bool:\n                 \"clear\",\n                 \"This clears the screen and starts a new session.\\n\"\n                 \"The current conversation history will be discarded.\",\n+                cmd_original=cmd_original,\n             ) is None:\n                 return\n             self.new_session(silent=True)","@@ -8225,12 +8226,16 @@ def process_command(self, command: str) -> bool:\n             if not self._handle_handoff_command(cmd_original):\n                 return False\n         elif canonical == \"new\":\n-            parts = cmd_original.split(maxsplit=1)\n-            title = parts[1].strip() if len(parts) > 1 else None\n+            # Strip inline-skip tokens (now/--yes/-y) before deriving the title\n+            # so \"/new now My Session\" yields title=\"My Session\" instead of\n+            # title=\"now My Session\". See _split_destructive_skip.\n+            _new_args, _ = self._split_destructive_skip(cmd_original)\n+            title = _new_args.strip() or None\n             if self._confirm_destructive_slash(\n                 \"new\",\n                 \"This starts a fresh session.\\n\"\n                 \"The current conversation history will be discarded.\",\n+                cmd_original=cmd_original,\n             ) is None:\n                 return\n             self.new_session(title=title)","@@ -8257,6 +8262,7 @@ def process_command(self, command: str) -> bool:\n             if self._confirm_destructive_slash(\n                 \"undo\",\n                 \"This removes the last user/assistant exchange from history.\",\n+                cmd_original=cmd_original,\n             ) is None:\n                 return\n             self.undo_last()","@@ -9908,7 +9914,49 @@ def _check_config_mcp_changes(self) -> None:\n         if _reload_thread.is_alive():\n             print(\"  ⚠️  MCP reload timed out (30s). Some servers may not have reconnected.\")\n \n-    def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]:\n+    # Inline-skip tokens that bypass the destructive-slash confirmation modal.\n+    # Matches the escape-hatch pattern users on broken modal platforms\n+    # (currently native Windows PowerShell — issue #30768) need to self-serve\n+    # without having to flip approvals.destructive_slash_confirm in config.\n+    _DESTRUCTIVE_SKIP_TOKENS = [redacted:secret-assignment]\"now\", \"--yes\", \"-y\"})\n+\n+    @classmethod\n+    def _split_destructive_skip(cls, cmd_text: Optional[str]) -> tuple[str, bool]:\n+        \"\"\"Split inline-skip tokens out of a destructive slash command.\n+\n+        Returns ``(remainder, skip)`` where ``remainder`` is the original\n+        text with the command word and any recognized skip tokens removed,\n+        and ``skip`` is True iff at least one skip token was found.\n+\n+        Examples:\n+            \"/reset now\"            -> (\"\", True)\n+            \"/reset --yes My title\" -> (\"My title\", True)\n+            \"/new My title\"         -> (\"My title\", False)\n+            \"/clear\"                -> (\"\", False)\n+        \"\"\"\n+        if not cmd_text:\n+            return \"\", False\n+        tokens = [redacted:secret-assignment]\n+        if not tokens:\n+            return \"\", False\n+        # Drop leading \"/cmd\" word — callers pass the full command text.\n+        if tokens[0].startswith(\"/\"):\n+            tokens = [redacted:secret-assignment]\n+        skip = False\n+        kept: list[str] = []\n+        for tok in tokens:\n+            if tok.lower() in cls._DESTRUCTIVE_SKIP_TOKENS:\n+                skip = True\n+                continue\n+            kept.append(tok)\n+        return \" \".join(kept), skip\n+\n+    def _confirm_destructive_slash(\n+        self,\n+        command: str,\n+        detail: str,\n+        cmd_original: Optional[str] = None,\n+    ) -> Optional[str]:\n         \"\"\"Prompt the user to confirm a destructive session slash command.\n \n         Used by ``/clear``, ``/new``/``/reset``, and ``/undo`` before they","@@ -9924,9 +9972,24 @@ def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]\n         gate is off the function returns ``\"once\"`` immediately without\n         prompting.\n \n+        Inline-skip: if ``cmd_original`` contains ``now``, ``--yes``, or\n+        ``-y`` as an argument (e.g. ``/reset now``, ``/new --yes My title``),\n+        the modal is bypassed and ``\"once\"`` is returned immediately. This is\n+        an escape hatch for platforms where the prompt_toolkit modal hangs\n+        (issue #30768 — native Windows PowerShell). Callers are responsible\n+        for stripping the skip tokens from any remaining argument parsing\n+        (see :meth:`_split_destructive_skip`).\n+\n         Returns ``\"once\"``, ``\"always\"``, or ``None`` (cancelled).  Callers\n         proceed with the destructive action when the result is non-None.\n         \"\"\"\n+        # Inline-skip escape hatch — works regardless of platform/modal state.\n+        # See class-level _DESTRUCTIVE_SKIP_TOKENS for the accepted tokens.\n+        if cmd_original:\n+            _, _skip = self._split_destructive_skip(cmd_original)\n+            if _skip:\n+                return \"once\"\n+\n         # Gate check — respects prior \"Always Approve\" clicks.\n         try:\n             cfg = load_cli_config()"]},{"file":"tests/cli/test_destructive_slash_confirm.py","hunks":["@@ -209,3 +209,123 @@ def test_slash_confirm_display_fragments_include_choice_mapping():\n     assert \"[2] Always Approve\" in rendered\n     assert \"[3] Cancel\" in rendered\n     assert \"Type 1/2/3\" in rendered\n+\n+\n+# ---------------------------------------------------------------------------\n+# Inline-skip escape hatch (issue #30768)\n+#\n+# Users on platforms where the prompt_toolkit modal doesn't dispatch keys\n+# (currently native Windows PowerShell) need a way to bypass the confirmation\n+# without flipping the config gate.  ``/reset now``, ``/new --yes``, ``/clear\n+# -y`` all skip the modal and return \"once\" immediately.\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_split_destructive_skip_recognized_tokens():\n+    \"\"\"``now``, ``--yes``, and ``-y`` are recognized as skip tokens.\"\"\"\n+    from cli import HermesCLI\n+\n+    assert HermesCLI._split_destructive_skip(\"/reset now\") == (\"\", True)\n+    assert HermesCLI._split_destructive_skip(\"/clear --yes\") == (\"\", True)\n+    assert HermesCLI._split_destructive_skip(\"/undo -y\") == (\"\", True)\n+\n+\n+def test_split_destructive_skip_strips_command_word():\n+    \"\"\"Leading ``/cmd`` token is stripped; remaining args survive.\"\"\"\n+    from cli import HermesCLI\n+\n+    assert HermesCLI._split_destructive_skip(\"/new My title\") == (\"My title\", False)\n+    assert HermesCLI._split_destructive_skip(\"/new --yes My title\") == (\"My title\", True)\n+\n+\n+def test_split_destructive_skip_case_insensitive():\n+    \"\"\"Token matching is case-insensitive but not a substring match.\"\"\"\n+    from cli import HermesCLI\n+\n+    assert HermesCLI._split_destructive_skip(\"/new NOW\") == (\"\", True)\n+    # Substring match must NOT trigger — \"Now-Title\" is a literal title token.\n+    assert HermesCLI._split_destructive_skip(\"/new Now-Title\") == (\"Now-Title\", False)\n+\n+\n+def test_split_destructive_skip_handles_empty_and_none():\n+    \"\"\"Defensive against missing/empty input.\"\"\"\n+    from cli import HermesCLI\n+\n+    assert HermesCLI._split_destructive_skip(None) == (\"\", False)\n+    assert HermesCLI._split_destructive_skip(\"\") == (\"\", False)\n+    assert HermesCLI._split_destructive_skip(\"   \") == (\"\", False)\n+\n+\n+def test_confirm_destructive_slash_now_skips_modal():\n+    \"\"\"``/reset now`` skips the modal even when the gate is on.\"\"\"\n+    from cli import HermesCLI\n+\n+    # Build a prompt stub that fails the test if invoked — proving the modal\n+    # was never reached.\n+    def _explode(**_kw):\n+        raise AssertionError(\"modal must not be invoked when inline-skip present\")\n+\n+    self_ = SimpleNamespace(\n+        _app=None,\n+        _prompt_text_input_modal=_explode,\n+    )\n+    self_._normalize_slash_confirm_choice = _bound(\n+        HermesCLI._normalize_slash_confirm_choice, self_,\n+    )\n+    self_._split_destructive_skip = HermesCLI._split_destructive_skip  # classmethod\n+\n+    with patch(\n+        \"cli.load_cli_config\",\n+        return_value={\"approvals\": {\"destructive_slash_confirm\": True}},\n+    ):\n+        result = _bound(HermesCLI._confirm_destructive_slash, self_)(\n+            \"new\", \"detail\", cmd_original=\"/reset now\",\n+        )\n+\n+    assert result == \"once\"\n+\n+\n+def test_confirm_destructive_slash_yes_flag_skips_modal():\n+    \"\"\"``--yes`` flag is equivalent to ``now``.\"\"\"\n+    from cli import HermesCLI\n+\n+    def _explode(**_kw):\n+        raise AssertionError(\"modal must not be invoked when --yes present\")\n+\n+    self_ = SimpleNamespace(\n+        _app=None,\n+        _prompt_text_input_modal=_explode,\n+    )\n+    self_._normalize_slash_confirm_choice = _bound(\n+        HermesCLI._normalize_slash_confirm_choice, self_,\n+    )\n+    self_._split_destructive_skip = HermesCLI._split_destructive_skip\n+\n+    with patch(\n+        \"cli.load_cli_config\",\n+        return_value={\"approvals\": {\"destructive_slash_confirm\": True}},\n+    ):\n+        result = _bound(HermesCLI._confirm_destructive_slash, self_)(\n+            \"new\", \"detail\", cmd_original=\"/new --yes My Session\",\n+        )\n+\n+    assert result == \"once\"\n+\n+\n+def test_confirm_destructive_slash_no_skip_token_still_prompts():\n+    \"\"\"Without a skip token the gate-on path still consults the modal.\"\"\"\n+    from cli import HermesCLI\n+\n+    self_ = _make_self(prompt_response=\"3\")  # cancel\n+    self_._split_destructive_skip = HermesCLI._split_destructive_skip\n+\n+    with patch(\n+        \"cli.load_cli_config\",\n+        return_value={\"approvals\": {\"destructive_slash_confirm\": True}},\n+    ):\n+        result = _bound(HermesCLI._confirm_destructive_slash, self_)(\n+            \"new\", \"detail\", cmd_original=\"/new My Session\",\n+        )\n+\n+    # Prompt was reached and returned cancel → None.\n+    assert result is None"]},{"file":"tests/cli/test_destructive_slash_inline_skip_e2e.py","hunks":["@@ -0,0 +1,129 @@\n+\"\"\"End-to-end integration test for the destructive-slash inline-skip path.\n+\n+Drives ``HermesCLI.process_command(\"/reset now\")`` against a minimal stand-in\n+and verifies:\n+\n+1. ``new_session`` was invoked (the command actually ran)\n+2. ``_prompt_text_input_modal`` was NOT invoked (modal bypassed)\n+3. The skip token did not leak into the session title\n+\n+This is the regression test for issue #30768 — the inline-skip escape hatch\n+must work without ever touching the modal, on every platform.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+\n+def _make_cli_stub():\n+    \"\"\"Build a minimal HermesCLI-shaped object that can run ``process_command``\n+    for the destructive-slash branches without spinning up a real TUI.\"\"\"\n+    from cli import HermesCLI\n+\n+    new_session_calls = []\n+\n+    def _capture_new_session(self_, title=None, silent=False):\n+        new_session_calls.append({\"title\": title, \"silent\": silent})\n+\n+    self_ = SimpleNamespace(\n+        _app=None,\n+        _prompt_text_input_modal=lambda **_kw: (_ for _ in ()).throw(\n+            AssertionError(\"modal must not be invoked when inline-skip token present\")\n+        ),\n+        new_session=lambda **kw: _capture_new_session(self_, **kw),\n+        # Stub out side-effects the destructive-slash branches reach for.\n+        console=SimpleNamespace(clear=lambda: None),\n+        compact=False,\n+        model=\"stub-model\",\n+        session_id=\"stub-session\",\n+        enabled_toolsets=[],\n+        _pending_title=None,\n+        _session_db=None,\n+    )\n+    # Bind the methods we need under test.\n+    self_._split_destructive_skip = HermesCLI._split_destructive_skip\n+    self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(\n+        self_, type(self_)\n+    )\n+    self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))\n+    return self_, new_session_calls\n+\n+\n+def test_reset_now_invokes_new_session_without_modal():\n+    \"\"\"``/reset now`` runs ``new_session`` and never touches the modal.\"\"\"\n+    self_, calls = _make_cli_stub()\n+\n+    with patch(\n+        \"cli.load_cli_config\",\n+        return_value={\"approvals\": {\"destructive_slash_confirm\": True}},\n+    ):\n+        self_.process_command(\"/reset now\")\n+\n+    assert calls, \"new_session was never invoked\"\n+    # The /new branch passes title=None when there's no non-skip remainder.\n+    assert calls[0][\"title\"] is None\n+\n+\n+def test_new_yes_with_title_preserves_title():\n+    \"\"\"``/new --yes My Session`` runs ``new_session(title='My Session')``.\"\"\"\n+    self_, calls = _make_cli_stub()\n+\n+    with patch(\n+        \"cli.load_cli_config\",\n+        return_value={\"approvals\": {\"destructive_slash_confirm\": True}},\n+    ):\n+        self_.process_command(\"/new --yes My Session\")\n+\n+    assert calls, \"new_session was never invoked\"\n+    assert calls[0][\"title\"] == \"My Session\"\n+\n+\n+def test_new_without_skip_token_still_consults_modal():\n+    \"\"\"``/new My Session`` (no skip token) must reach the modal.\n+\n+    Sanity check that we haven't accidentally short-circuited the normal path.\n+    \"\"\"\n+    from cli import HermesCLI\n+\n+    new_session_calls = []\n+    modal_calls = []\n+\n+    def _capture_new_session(self_, title=None, silent=False):\n+        new_session_calls.append({\"title\": title, \"silent\": silent})\n+\n+    def _record_modal(**kw):\n+        modal_calls.append(kw)\n+        # Simulate user cancelling so new_session is not called.\n+        return \"3\"\n+\n+    self_ = SimpleNamespace(\n+        _app=None,\n+        _prompt_text_input_modal=_record_modal,\n+        new_session=lambda **kw: _capture_new_session(self_, **kw),\n+        console=SimpleNamespace(clear=lambda: None),\n+        compact=False,\n+        model=\"stub-model\",\n+        session_id=\"stub-session\",\n+        enabled_toolsets=[],\n+        _pending_title=None,\n+        _session_db=None,\n+    )\n+    self_._split_destructive_skip = HermesCLI._split_destructive_skip\n+    self_._normalize_slash_confirm_choice = HermesCLI._normalize_slash_confirm_choice.__get__(\n+        self_, type(self_)\n+    )\n+    self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(\n+        self_, type(self_)\n+    )\n+    self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))\n+\n+    with patch(\n+        \"cli.load_cli_config\",\n+        return_value={\"approvals\": {\"destructive_slash_confirm\": True}},\n+    ):\n+        self_.process_command(\"/new My Session\")\n+\n+    assert modal_calls, \"modal must be reached when no skip token is present\"\n+    assert not new_session_calls, \"user cancelled — new_session must not run\""]},{"file":"website/docs/reference/slash-commands.md","hunks":["@@ -36,7 +36,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in\n \n | Command | Description |\n |---------|-------------|\n-| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. |\n+| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. Append `now`, `--yes`, or `-y` to skip the confirmation modal — e.g. `/reset now`, `/new --yes my-experiment`. |\n | `/clear` | Clear screen and start a new session |\n | `/history` | Show conversation history |\n | `/save` | Save the current conversation |","@@ -252,4 +252,6 @@ The CLI prompts before running slash commands that throw away unsaved session st\n \n For each of these the CLI opens a three-choice modal: **Approve Once** (proceed this time), **Always Approve** (proceed and persist `approvals.destructive_slash_confirm: false` so future destructive commands run without prompting), or **Cancel**.\n \n+**Inline skip:** append `now`, `--yes`, or `-y` to bypass the modal for a single invocation — e.g. `/reset now`, `/new --yes my-session`, `/clear -y`, `/undo -y`. Useful when the modal doesn't render correctly on your terminal (see [issue #30768](https://github.com/NousResearch/hermes-agent/issues/30768) for native Windows PowerShell) or when scripting against the CLI.\n+\n Set `approvals.destructive_slash_confirm: false` in `~/.hermes/config.yaml` to disable the prompts globally; set it back to `true` to re-enable. See [Security — Destructive slash command confirmation](../user-guide/security.md#dangerous-command-approval) for context.\n"]}]},{"kind":"pr","number":32013,"title":"fix(cli): prevent /reset and /new freeze on Windows by falling back to stdin prompt (#30768)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","scripts/release.py","tests/cli/test_slash_confirm_windows.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -7034,7 +7034,28 @@ def _prompt_text_input_modal(\n         could be interpreted as EOF/exit.  A first-class modal state keeps the\n         choices visible and lets the normal Enter key binding submit the typed\n         or highlighted choice.\n+\n+        **Platform note (Windows dead-lock — issue #30768):**\n+        The queue-based modal relies on prompt_toolkit key bindings receiving\n+        keyboard events and calling ``_submit_slash_confirm_response``.  On\n+        Windows (PowerShell / Windows Terminal) the prompt_toolkit input\n+        channel can become unresponsive when the modal is entered from the\n+        ``process_loop`` daemon thread, causing a dead-lock: the user sees the\n+        confirmation panel but keystrokes never reach the key bindings and the\n+        ``response_queue.get()`` blocks until the 120-second timeout expires.\n+\n+        To avoid this, we fall back to ``_prompt_text_input`` (a simple\n+        ``input()``-based prompt) when any of these conditions hold:\n+\n+        * ``sys.platform == \"win32\"`` — native Windows console (ConPTY /\n+          win32_input) does not support the modal reliably.\n+        * Called from a non-main thread — the prompt_toolkit event loop only\n+          runs on the main thread; key bindings can't fire from a daemon\n+          thread (same rationale as the ``_prompt_text_input`` thread guard\n+          in PR #23454).\n+        * ``self._app`` is not set — unit tests / non-interactive contexts.\n         \"\"\"\n+        import threading\n         import time as _time\n \n         if not choices:","@@ -7045,6 +7066,20 @@ def _prompt_text_input_modal(\n         if not getattr(self, \"_app\", None):\n             return self._prompt_text_input(\"Choice [1/2/3]: \")\n \n+        # On Windows the prompt_toolkit input channel can deadlock when the\n+        # modal is entered from the process_loop daemon thread — keystrokes\n+        # never reach the key bindings, so response_queue.get() blocks for\n+        # the full timeout (issue #30768).  Fall back to the simpler\n+        # stdin-based prompt which works reliably on Windows.\n+        if sys.platform == \"win32\":\n+            return self._prompt_text_input(\"Choice [1/2/3]: \")\n+\n+        # Mirror the thread-aware guard from _prompt_text_input (PR #23454):\n+        # run_in_terminal and the modal queue both depend on the main-thread\n+        # event loop.  From a daemon thread the modal key bindings never fire.\n+        if threading.current_thread() is not threading.main_thread():\n+            return self._prompt_text_input(\"Choice [1/2/3]: \")\n+\n         response_queue = queue.Queue()\n         self._capture_modal_input_snapshot()\n         self._slash_confirm_state = {"]},{"file":"scripts/release.py","hunks":["@@ -1301,6 +1301,7 @@\n     \"[redacted:email]\": \"SamuelZ12\",  # PR #7480 (show recap after in-session resume)\n     \"[redacted:email]\": \"daizhonggeng\",  # PR #9020 (numbered /resume selection)\n     \"[redacted:email]\": \"wanwan2qq\",  # PR #10215 (strip brackets/quotes from /resume; gateway session-ID lookup)\n+    \"[redacted:email]\": \"SimoKiihamaki\",  # PR #30773 (Windows /reset+/new freeze; stdin fallback for modal)\n }\n \n "]},{"file":"tests/cli/test_slash_confirm_windows.py","hunks":["@@ -0,0 +1,259 @@\n+\"\"\"Regression tests for issue #30768: /reset and /new freeze on Windows.\n+\n+``_prompt_text_input_modal`` uses a queue-based modal that relies on\n+prompt_toolkit key bindings receiving keyboard events.  On Windows the\n+prompt_toolkit input channel can deadlock when the modal is entered from\n+the ``process_loop`` daemon thread.  The fix falls back to the simpler\n+``_prompt_text_input`` (stdin-based) prompt on Windows and non-main threads.\n+\n+These tests verify:\n+1. Windows detection triggers the stdin fallback\n+2. Non-main thread detection triggers the stdin fallback\n+3. macOS/Linux main-thread path still uses the modal (no regression)\n+4. No-app path still uses the stdin fallback (existing behavior)\n+5. Empty choices returns None (existing behavior)\n+\"\"\"\n+\n+import queue\n+import sys\n+import threading\n+import time\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+def _make_cli():\n+    \"\"\"Minimal HermesCLI shell exposing prompt/modal helpers.\"\"\"\n+    import cli as cli_mod\n+\n+    obj = object.__new__(cli_mod.HermesCLI)\n+    obj._app = MagicMock()\n+    obj._status_bar_visible = True\n+    obj._last_invalidate = 0.0\n+    obj._modal_input_snapshot = None\n+    obj._slash_confirm_state = None\n+    obj._slash_confirm_deadline = 0\n+    return obj\n+\n+\n+# ---------------------------------------------------------------------------\n+# Sample choices used across tests\n+# ---------------------------------------------------------------------------\n+_SAMPLE_CHOICES = [\n+    (\"once\", \"Approve Once\", \"proceed this time only\"),\n+    (\"always\", \"Always Approve\", \"proceed and silence this prompt permanently\"),\n+    (\"cancel\", \"Cancel\", \"keep current conversation\"),\n+]\n+\n+\n+class TestModalWindowsFallback:\n+    \"\"\"Windows dead-lock regression tests for _prompt_text_input_modal.\"\"\"\n+\n+    def test_windows_falls_back_to_stdin(self):\n+        \"\"\"On Windows, _prompt_text_input_modal should use _prompt_text_input.\"\"\"\n+        cli = _make_cli()\n+\n+        with patch.object(sys, \"platform\", \"win32\"), \\\n+             patch.object(cli, \"_prompt_text_input\", return_value=\"1\") as mock_stdin:\n+            result = cli._prompt_text_input_modal(\n+                title=\"⚠️  /new — destroys conversation state\",\n+                detail=\"This starts a fresh session.\",\n+                choices=_SAMPLE_CHOICES,\n+            )\n+\n+        # The stdin-based fallback was used, not the modal queue path.\n+        mock_stdin.assert_called_once_with(\"Choice [1/2/3]: \")\n+        assert result == \"1\"\n+\n+    def test_non_main_thread_falls_back_to_stdin(self):\n+        \"\"\"Off the main thread, _prompt_text_input_modal should use stdin fallback.\"\"\"\n+        cli = _make_cli()\n+        result_holder = {}\n+\n+        def run_on_daemon():\n+            # Patch platform to \"linux\" so the Windows check doesn't short-circuit.\n+            with patch.object(sys, \"platform\", \"linux\"), \\\n+                 patch.object(cli, \"_prompt_text_input\", return_value=\"2\") as mock_stdin:\n+                result_holder[\"result\"] = cli._prompt_text_input_modal(\n+                    title=\"⚠️  /reset\",\n+                    detail=\"This starts a fresh session.\",\n+                    choices=_SAMPLE_CHOICES,\n+                )\n+                result_holder[\"stdin_called\"] = mock_stdin.called\n+\n+        t = threading.Thread(target=run_on_daemon, daemon=True)\n+        t.start()\n+        t.join(timeout=2.0)\n+        assert not t.is_alive(), \"daemon thread hung — modal deadlocked\"\n+        assert result_holder[\"stdin_called\"] is True\n+        assert result_holder[\"result\"] == \"2\"\n+\n+    def test_main_thread_non_windows_uses_modal(self):\n+        \"\"\"On macOS/Linux main thread, the queue-based modal is still used.\"\"\"\n+        cli = _make_cli()\n+\n+        # We need to simulate the modal receiving a response. We'll patch\n+        # the response_queue to immediately return a value.\n+        with patch.object(sys, \"platform\", \"darwin\"), \\\n+             patch.object(cli, \"_capture_modal_input_snapshot\"), \\\n+             patch.object(cli, \"_restore_modal_input_snapshot\"), \\\n+             patch.object(cli, \"_invalidate\"):\n+            # Start the modal in a way that it will receive a response\n+            # immediately via the queue.\n+            original_queue = queue.Queue\n+            original_time = time.monotonic\n+\n+            def _fake_modal_flow(*args, **kwargs):\n+                \"\"\"Simulate the modal flow: set state, put response, return.\"\"\"\n+                # We'll directly test that the modal path is entered by\n+                # checking that _slash_confirm_state was set.\n+                pass\n+\n+            # Since we can't easily mock the internal queue, let's test\n+            # that the modal path is entered by checking that\n+            # _prompt_text_input was NOT called.\n+            with patch.object(cli, \"_prompt_text_input\") as mock_stdin:\n+                # Set up a response that will be put into the queue\n+                # after the modal starts waiting.\n+                def _submit_after_delay():\n+                    time.sleep(0.2)\n+                    state = cli._slash_confirm_state\n+                    if state and \"response_queue\" in state:\n+                        state[\"response_queue\"].put(\"once\")\n+\n+                submitter = threading.Thread(target=_submit_after_delay, daemon=True)\n+                submitter.start()\n+\n+                result = cli._prompt_text_input_modal(\n+                    title=\"⚠️  /new\",\n+                    detail=\"This starts a fresh session.\",\n+                    choices=_SAMPLE_CHOICES,\n+                    timeout=5,\n+                )\n+\n+                submitter.join(timeout=2.0)\n+\n+            # The stdin fallback should NOT have been called.\n+            mock_stdin.assert_not_called()\n+            # The result should be \"once\" from the simulated modal response.\n+            assert result == \"once\"\n+\n+    def test_no_app_falls_back_to_stdin(self):\n+        \"\"\"Without a prompt_toolkit app, always use stdin fallback.\"\"\"\n+        cli = _make_cli()\n+        cli._app = None\n+\n+        with patch.object(cli, \"_prompt_text_input\", return_value=\"3\") as mock_stdin:\n+            result = cli._prompt_text_input_modal(\n+                title=\"⚠️  /clear\",\n+                detail=\"This clears the screen.\",\n+                choices=_SAMPLE_CHOICES,\n+            )\n+\n+        mock_stdin.assert_called_once_with(\"Choice [1/2/3]: \")\n+        assert result == \"3\"\n+\n+    def test_empty_choices_returns_none(self):\n+        \"\"\"Empty choices list should return None without prompting.\"\"\"\n+        cli = _make_cli()\n+\n+        with patch.object(cli, \"_prompt_text_input\") as mock_stdin:\n+            result = cli._prompt_text_input_modal(\n+                title=\"Test\",\n+                detail=\"Test\",\n+                choices=[],\n+            )\n+\n+        mock_stdin.assert_not_called()\n+        assert result is None\n+\n+    def test_windows_fallback_does_not_set_modal_state(self):\n+        \"\"\"Verify Windows fallback doesn't leave _slash_confirm_state set.\"\"\"\n+        cli = _make_cli()\n+\n+        with patch.object(sys, \"platform\", \"win32\"), \\\n+             patch.object(cli, \"_prompt_text_input\", return_value=\"1\"):\n+            cli._prompt_text_input_modal(\n+                title=\"⚠️  /reset\",\n+                detail=\"This starts a fresh session.\",\n+                choices=_SAMPLE_CHOICES,\n+            )\n+\n+        assert cli._slash_confirm_state is None\n+\n+    def test_non_main_thread_fallback_does_not_set_modal_state(self):\n+        \"\"\"Verify daemon-thread fallback doesn't leave modal state set.\"\"\"\n+        cli = _make_cli()\n+        errors = []\n+\n+        def run_on_daemon():\n+            try:\n+                with patch.object(sys, \"platform\", \"linux\"), \\\n+                     patch.object(cli, \"_prompt_text_input\", return_value=\"1\"):\n+                    cli._prompt_text_input_modal(\n+                        title=\"⚠️  /new\",\n+                        detail=\"This starts a fresh session.\",\n+                        choices=_SAMPLE_CHOICES,\n+                    )\n+                if cli._slash_confirm_state is not None:\n+                    errors.append(\"_slash_confirm_state should be None\")\n+            except Exception as exc:\n+                errors.append(str(exc))\n+\n+        t = threading.Thread(target=run_on_daemon, daemon=True)\n+        t.start()\n+        t.join(timeout=2.0)\n+        assert not errors, f\"unexpected errors: {errors}\"\n+        assert cli._slash_confirm_state is None\n+\n+\n+class TestConfirmDestructiveSlashWindows:\n+    \"\"\"Integration-level tests for _confirm_destructive_slash on Windows.\"\"\"\n+\n+    def test_confirm_destructive_slash_bypasses_modal_on_windows(self):\n+        \"\"\"_confirm_destructive_slash should work on Windows via stdin fallback.\"\"\"\n+        cli = _make_cli()\n+        cli.model = \"test-model\"\n+        cli._agent_running = False\n+        cli._spinner_text = \"\"\n+        cli._should_exit = False\n+        cli._command_running = False\n+        cli.session_id = \"test-session\"\n+        cli._pending_tool_info = {}\n+        cli._tool_start_time = 0.0\n+        cli._last_scrollback_tool = \"\"\n+\n+        with patch.object(sys, \"platform\", \"win32\"), \\\n+             patch.object(cli, \"_prompt_text_input\", return_value=\"1\"), \\\n+             patch(\"cli.load_cli_config\", return_value={\"approvals\": {\"destructive_slash_confirm\": True}}):\n+            result = cli._confirm_destructive_slash(\n+                \"new\",\n+                \"This starts a fresh session.\\nThe current conversation history will be discarded.\",\n+            )\n+\n+        assert result == \"once\"\n+\n+    def test_confirm_destructive_slash_cancelled_on_windows(self):\n+        \"\"\"Cancellation via stdin fallback works on Windows.\"\"\"\n+        cli = _make_cli()\n+        cli.model = \"test-model\"\n+        cli._agent_running = False\n+        cli._spinner_text = \"\"\n+        cli._should_exit = False\n+        cli._command_running = False\n+        cli.session_id = \"test-session\"\n+        cli._pending_tool_info = {}\n+        cli._tool_start_time = 0.0\n+        cli._last_scrollback_tool = \"\"\n+\n+        with patch.object(sys, \"platform\", \"win32\"), \\\n+             patch.object(cli, \"_prompt_text_input\", return_value=\"3\"), \\\n+             patch(\"cli.load_cli_config\", return_value={\"approvals\": {\"destructive_slash_confirm\": True}}):\n+            result = cli._confirm_destructive_slash(\n+                \"reset\",\n+                \"This starts a fresh session.\\nThe current conversation history will be discarded.\",\n+            )\n+\n+        # Choice \"3\" normalizes to \"cancel\", which returns None.\n+        assert result is None\n"]}]},{"kind":"pr","number":32387,"title":"fix(cli): keep destructive slash modal on Linux","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33241","number":33241},{"kind":"pr","value":"#33165","number":33165},{"kind":"pr","value":"#32383","number":32383}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":32657,"title":"fix: add timeout protection to /new and /clear commands (Issue #32383)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32387","number":32387}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":32808,"title":"fix(cli): keep slash confirmation modal on app loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32387","number":32387},{"kind":"pr","value":"#32013","number":32013},{"kind":"pr","value":"#32383","number":32383},{"kind":"pr","value":"#32387","number":32387},{"kind":"pr","value":"#32383","number":32383},{"kind":"pr","value":"#22958","number":22958},{"kind":"pr","value":"#32013","number":32013}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":33241,"title":"fix(cli): keep destructive slash modal on Linux/macOS daemon thread","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_slash_confirm_windows.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -7154,11 +7154,13 @@ def _prompt_text_input_modal(\n \n         * ``sys.platform == \"win32\"`` — native Windows console (ConPTY /\n           win32_input) does not support the modal reliably.\n-        * Called from a non-main thread — the prompt_toolkit event loop only\n-          runs on the main thread; key bindings can't fire from a daemon\n-          thread (same rationale as the ``_prompt_text_input`` thread guard\n-          in PR #23454).\n         * ``self._app`` is not set — unit tests / non-interactive contexts.\n+\n+        On non-Windows platforms the modal itself is still safe from the\n+        ``process_loop`` daemon thread as long as the main-thread event loop\n+        owns the prompt_toolkit buffer mutations.  When we are off the main\n+        thread, schedule the modal snapshot / restore work on ``self._app.loop``\n+        via ``call_soon_threadsafe`` and keep the queue-based response path.\n         \"\"\"\n         import threading\n         import time as _time","@@ -7179,33 +7181,62 @@ def _prompt_text_input_modal(\n         if sys.platform == \"win32\":\n             return self._prompt_text_input(\"Choice [1/2/3]: \")\n \n-        # Mirror the thread-aware guard from _prompt_text_input (PR #23454):\n-        # run_in_terminal and the modal queue both depend on the main-thread\n-        # event loop.  From a daemon thread the modal key bindings never fire.\n-        if threading.current_thread() is not threading.main_thread():\n+        try:\n+            app_loop = self._app.loop\n+        except Exception:\n+            app_loop = None\n+\n+        in_main_thread = threading.current_thread() is threading.main_thread()\n+        if not in_main_thread and app_loop is None:\n             return self._prompt_text_input(\"Choice [1/2/3]: \")\n \n         response_queue = queue.Queue()\n-        self._capture_modal_input_snapshot()\n-        self._slash_confirm_state = {\n-            \"title\": title,\n-            \"detail\": detail,\n-            \"choices\": choices,\n-            \"selected\": 0,\n-            \"response_queue\": response_queue,\n-        }\n-        self._slash_confirm_deadline = _time.monotonic() + timeout\n-        self._invalidate()\n+\n+        def _setup_modal() -> None:\n+            self._capture_modal_input_snapshot()\n+            self._slash_confirm_state = {\n+                \"title\": title,\n+                \"detail\": detail,\n+                \"choices\": choices,\n+                \"selected\": 0,\n+                \"response_queue\": response_queue,\n+            }\n+            self._slash_confirm_deadline = _time.monotonic() + timeout\n+            self._invalidate()\n+\n+        def _teardown_modal() -> None:\n+            self._slash_confirm_state = None\n+            self._slash_confirm_deadline = 0\n+            self._restore_modal_input_snapshot()\n+            self._invalidate()\n+\n+        def _run_on_app_loop(fn) -> bool:\n+            if in_main_thread or app_loop is None:\n+                fn()\n+                return True\n+            ready = threading.Event()\n+\n+            def _wrapped() -> None:\n+                try:\n+                    fn()\n+                finally:\n+                    ready.set()\n+\n+            try:\n+                app_loop.call_soon_threadsafe(_wrapped)\n+            except Exception:\n+                return False\n+            return ready.wait(timeout=5)\n+\n+        if not _run_on_app_loop(_setup_modal):\n+            return self._prompt_text_input(\"Choice [1/2/3]: \")\n \n         _last_countdown_refresh = _time.monotonic()\n         try:\n             while True:\n                 try:\n                     result = response_queue.get(timeout=1)\n-                    self._slash_confirm_state = None\n-                    self._slash_confirm_deadline = 0\n-                    self._restore_modal_input_snapshot()\n-                    self._invalidate()\n+                    _run_on_app_loop(_teardown_modal)\n                     return result\n                 except queue.Empty:\n                     remaining = self._slash_confirm_deadline - _time.monotonic()","@@ -7217,10 +7248,7 @@ def _prompt_text_input_modal(\n                         self._invalidate()\n         finally:\n             if self._slash_confirm_state is not None:\n-                self._slash_confirm_state = None\n-                self._slash_confirm_deadline = 0\n-                self._restore_modal_input_snapshot()\n-                self._invalidate()\n+                _run_on_app_loop(_teardown_modal)\n         return None\n \n     def _submit_slash_confirm_response(self, value: str | None) -> None:"]},{"file":"tests/cli/test_slash_confirm_windows.py","hunks":["@@ -1,14 +1,14 @@\n-\"\"\"Regression tests for issue #30768: /reset and /new freeze on Windows.\n+\"\"\"Regression tests for issue #30768 and #32383.\n \n ``_prompt_text_input_modal`` uses a queue-based modal that relies on\n prompt_toolkit key bindings receiving keyboard events.  On Windows the\n prompt_toolkit input channel can deadlock when the modal is entered from\n the ``process_loop`` daemon thread.  The fix falls back to the simpler\n-``_prompt_text_input`` (stdin-based) prompt on Windows and non-main threads.\n+``_prompt_text_input`` (stdin-based) prompt on Windows.\n \n These tests verify:\n 1. Windows detection triggers the stdin fallback\n-2. Non-main thread detection triggers the stdin fallback\n+2. Non-Windows daemon threads still use the modal via the app loop\n 3. macOS/Linux main-thread path still uses the modal (no regression)\n 4. No-app path still uses the stdin fallback (existing behavior)\n 5. Empty choices returns None (existing behavior)","@@ -29,6 +29,7 @@ def _make_cli():\n \n     obj = object.__new__(cli_mod.HermesCLI)\n     obj._app = MagicMock()\n+    obj._app.loop = MagicMock()\n     obj._status_bar_visible = True\n     obj._last_invalidate = 0.0\n     obj._modal_input_snapshot = None","@@ -66,28 +67,47 @@ def test_windows_falls_back_to_stdin(self):\n         mock_stdin.assert_called_once_with(\"Choice [1/2/3]: \")\n         assert result == \"1\"\n \n-    def test_non_main_thread_falls_back_to_stdin(self):\n-        \"\"\"Off the main thread, _prompt_text_input_modal should use stdin fallback.\"\"\"\n+    def test_non_main_thread_uses_modal_via_app_loop(self):\n+        \"\"\"Off the main thread on Linux, keep the modal path via app-loop setup.\"\"\"\n         cli = _make_cli()\n         result_holder = {}\n+        setup_calls = []\n+        teardown_calls = []\n+\n+        def _call_soon_threadsafe(callback):\n+            callback()\n \n         def run_on_daemon():\n-            # Patch platform to \"linux\" so the Windows check doesn't short-circuit.\n             with patch.object(sys, \"platform\", \"linux\"), \\\n-                 patch.object(cli, \"_prompt_text_input\", return_value=\"2\") as mock_stdin:\n+                 patch.object(cli._app.loop, \"call_soon_threadsafe\", side_effect=_call_soon_threadsafe), \\\n+                 patch.object(cli, \"_prompt_text_input\") as mock_stdin, \\\n+                 patch.object(cli, \"_capture_modal_input_snapshot\", side_effect=lambda: setup_calls.append(\"capture\")), \\\n+                 patch.object(cli, \"_restore_modal_input_snapshot\", side_effect=lambda: teardown_calls.append(\"restore\")):\n                 result_holder[\"result\"] = cli._prompt_text_input_modal(\n                     title=\"⚠️  /reset\",\n                     detail=\"This starts a fresh session.\",\n                     choices=_SAMPLE_CHOICES,\n+                    timeout=5,\n                 )\n                 result_holder[\"stdin_called\"] = mock_stdin.called\n \n+        def _submit_after_delay():\n+            time.sleep(0.2)\n+            state = cli._slash_confirm_state\n+            if state and \"response_queue\" in state:\n+                state[\"response_queue\"].put(\"once\")\n+\n+        submitter = threading.Thread(target=_submit_after_delay, daemon=True)\n         t = threading.Thread(target=run_on_daemon, daemon=True)\n+        submitter.start()\n         t.start()\n         t.join(timeout=2.0)\n+        submitter.join(timeout=2.0)\n         assert not t.is_alive(), \"daemon thread hung — modal deadlocked\"\n-        assert result_holder[\"stdin_called\"] is True\n-        assert result_holder[\"result\"] == \"2\"\n+        assert result_holder[\"stdin_called\"] is False\n+        assert result_holder[\"result\"] == \"once\"\n+        assert setup_calls == [\"capture\"]\n+        assert teardown_calls == [\"restore\"]\n \n     def test_main_thread_non_windows_uses_modal(self):\n         \"\"\"On macOS/Linux main thread, the queue-based modal is still used.\"\"\"","@@ -182,20 +202,33 @@ def test_windows_fallback_does_not_set_modal_state(self):\n \n         assert cli._slash_confirm_state is None\n \n-    def test_non_main_thread_fallback_does_not_set_modal_state(self):\n-        \"\"\"Verify daemon-thread fallback doesn't leave modal state set.\"\"\"\n+    def test_non_main_thread_modal_clears_state(self):\n+        \"\"\"Verify daemon-thread modal teardown does not leave state behind.\"\"\"\n         cli = _make_cli()\n         errors = []\n \n+        def _call_soon_threadsafe(callback):\n+            callback()\n+\n         def run_on_daemon():\n             try:\n                 with patch.object(sys, \"platform\", \"linux\"), \\\n-                     patch.object(cli, \"_prompt_text_input\", return_value=\"1\"):\n+                     patch.object(cli._app.loop, \"call_soon_threadsafe\", side_effect=_call_soon_threadsafe):\n+                    def _submit_after_delay():\n+                        time.sleep(0.2)\n+                        state = cli._slash_confirm_state\n+                        if state and \"response_queue\" in state:\n+                            state[\"response_queue\"].put(\"cancel\")\n+\n+                    submitter = threading.Thread(target=_submit_after_delay, daemon=True)\n+                    submitter.start()\n                     cli._prompt_text_input_modal(\n                         title=\"⚠️  /new\",\n                         detail=\"This starts a fresh session.\",\n                         choices=_SAMPLE_CHOICES,\n+                        timeout=5,\n                     )\n+                    submitter.join(timeout=2.0)\n                 if cli._slash_confirm_state is not None:\n                     errors.append(\"_slash_confirm_state should be None\")\n             except Exception as exc:\n"]}]},{"kind":"pr","number":37237,"title":"fix(cli): restore cooked mode before input() on MSYS2/Git Bash to prevent deadlock","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ab98818e5bf3d721d01f1b1899f60ceaeab2a2e9","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":37297,"title":"fix(cli): restore cooked terminal mode on Windows/MSYS2 to prevent input() deadlock (#37230)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#37230","number":37230},{"kind":"pr","value":"#36844","number":36844}],"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":39446,"title":"fix(cli): keep /reload-mcp off the input thread","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"972aa33d376e8cb308466192ee545c879459fd0d","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":75084,"title":"fix(cli): stop /reload-mcp from blocking the command processor (supersedes #39446)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"972aa33d37","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}