{"repo":"NousResearch/hermes-agent","public_id":"23155","display_label":"TUI confirmation dialog rendering fix attempts","summary":"Multiple related efforts addressing the same issue: confirmation dialogs for destructive slash commands (like /clear) rendering as raw text instead of through the proper TUI rendering path. Members appear to be variants or iterations of the same fix.","subsystem":"hermes_cli","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":23907},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-11T17:02:03+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":23155,"pr_number":23907,"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}],"members":[{"kind":"issue","number":23155,"title":"fix: /clear confirmation dialog renders as raw text in TUI mode","url":null,"state":"closed"},{"kind":"pr","number":23173,"title":"fix(tui): route confirmation dialog through _cprint for TUI rendering","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":23176,"title":"fix(tui): render destructive slash confirmations via cprint","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23907","number":23907},{"kind":"pr","value":"#23907","number":23907}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":23290,"title":"fix(cli): render /clear confirmation dialog via _cprint in TUI mode","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":23341,"title":"fix(cli): route /clear confirmation dialog through TUI renderer","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23173","number":23173},{"kind":"pr","value":"#23155","number":23155}],"category":"duplicate_of","implication":"duplicate"},"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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}