# NousResearch/hermes-agent · TUI confirmation dialog rendering fix attempts

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 6 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #23907. Verify it addresses your case.
- **Recommended:** read PR #23907 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.33`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #23155: fix: /clear confirmation dialog renders as raw text in TUI mode

## Prior & competing PRs

- **#23907: fix(cli): use prompt_toolkit modal for destructive slash confirmations**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `scripts/release.py`, `tests/cli/test_destructive_slash_confirm.py`, `tests/cli/test_prompt_text_input_thread_safety.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -2558,6 +2558,8 @@ def __init__(
             self._approval_state = None
             self._approval_deadline = 0
             self._approval_lock = threading.Lock()
    +        self._slash_confirm_state = None
    +        self._slash_confirm_deadline = 0
             self._model_picker_state = None
             self._secret_state = None
             self._secret_deadline = 0
    ```

    ```diff
    @@ -3715,7 +3717,7 @@ def _open_external_editor(self, buffer=None) -> bool:
             if self._command_running:
                 _cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}")
                 return False
    -        if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state:
    +        if self._sudo_state or self._secret_state or self._approval_state or getattr(self, "_slash_confirm_state", None) or self._clarify_state:
                 _cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}")
                 return False
             target_buffer = buffer or getattr(app, "current_buffer", None)
    ```

    ```diff
    @@ -6059,6 +6061,194 @@ def _ask():
                 _ask()
             return result[0]
     
    +    def _prompt_text_input_modal(
    +        self,
    +        *,
    +        title: str,
    +        detail: str,
    +        choices: list[tuple[str, str, str]],
    +        timeout: float = 120,
    +    ) -> str | None:
    +        """Prompt through the prompt_toolkit composer instead of raw input().
    +
    +        This is for CLI slash-command confirmations.  The old raw input() path
    +        fought prompt_toolkit's active stdin ownership: in some terminals the
    +        prompt appeared above the TUI, choices were redrawn later, and Enter
    +        could be interpreted as EOF/exit.  A first-class modal state keeps the
    +        choices visible and lets the normal Enter key binding submit the typed
    +        or highlighted choice.
    +        """
    +        import time as _time
    +
    +        if not choices:
    +            return None
    +
    +        # If prompt_toolkit is not running (unit tests / non-interactive calls),
    +        # keep the simple stdin fallback.
    +        if not getattr(self, "_app", None):
    +            return self._prompt_text_input("Choice [1/2/3]: ")
    +
    +        response_queue = queue.Queue()
    +        self._capture_modal_input_snapshot()
    +        self._slash_confirm_state = {
    +            "title": title,
    +            "detail": detail,
    +            "choices": choices,
    +            "selected": 0,
    +            "response_queue": response_queue,
    +        }
    +        self._slash_confirm_deadline = _time.monotonic() + timeout
    +        self._invalidate()
    +
    +        _last_countdown_refresh = _time.monotonic()
    +        try:
    +            while True:
    +                try:
    +                    result = response_queue.get(timeout=1)
    +                    self._slash_confirm_state = None
    +                    self._slash_confirm_deadline = 0
    +                    self._restore_modal_input_snapshot()
    +                    self._invalidate()
    +                    return result
    +                except queue.Empty:
    +                    remaining = self._slash_confirm_deadline - _time.monotonic()
    +                    if remaining <= 0:
    +                        break
    +                    now = _time.monotonic()
    +                    if now - _last_countdown_refresh >= 5.0:
    +                        _last_countdown_refresh = now
    +                        self._invalidate()
    +        finally:
    +            if self._slash_confirm_state is not None:
    +                self._slash_confirm_state = None
    +                self._slash_confirm_deadline = 0
    +                self._restore_modal_input_snapshot()
    +                self._invalidate()
    +        return None
    +
    +    def _submit_slash_confirm_response(self, value: str | None) -> None:
    +        state = self._slash_confirm_state
    +        if not state:
    +            return
    +        state["response_queue"].put(value)
    +        self._slash_confirm_state = None
    +        self._slash_confirm_deadline = 0
    +        self._invalidate()
    +
    +    def _normalize_slash_confirm_choice(
    +        self,
    +        raw: str | None,
    +        choices: list[tuple[str, str, str]],
    +    ) -> str | None:
    +        if raw is None:
    +            return None
    +        choice_raw = raw.strip().lower()
    +        if not choice_raw:
    +            return None
    +        aliases = {
    +            "1": "once",
    +            "once": "once",
    +            "approve": "once",
    +            "yes": "once",
    +            "y": "once",
    +            "ok": "once",
    +            "2": "always",
    +            "always": "always",
    +            "remember": "always",
    +            "3": "cancel",
    +            "cancel": "cancel",
    +            "nevermind": "cancel",
    +            "no": "cancel",
    +            "n": "cancel",
    +        }
    +        allowed = {choice[0] for choice in choices}
    +        normalized = aliases.get(choice_raw)
    +        if normalized in allowed:
    +            return normalized
    +        if choice_raw in allowed:
    +            return choice_raw
    +        return None
    +
    +    def _get_slash_confirm_display_fragments(self):
    +        """Render the /new-/clear-style confirmation panel."""
    +        state = self._slash_confirm_state
    +        if not state:
    +            return []
    +
    +        title = state.get("title") or "Confirm action"
    +        detail = state.get("detail") or ""
    +        choices = state.get("choices") or []
    +        selected = state.get("selected", 0)
    +
    +        def _panel_box_width(title_text: str, content_lines: list[str], min_width: int = 56, max_width: int = 86) -> int:
    +            term_cols = shutil.get_terminal_size((100, 20)).columns
    +            longest = max([len(title_text)] + [len(line) for line in content_lines] + [min_width - 4])
    +            inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6))
    +            return inner + 2
    +
    +        def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]:
    +            wrapped = textwrap.wrap(
    +                text,
    +                width=max(8, width),
    +                replace_whitespace=False,
    +                drop_whitespace=False,
    +                subsequent_indent=subsequent_indent,
    +            )
    +            return wrapped or [""]
    +
    +        def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None:
    +            inner_width = max(0, box_width - 2)
    +            lines.append((border_style, "│ "))
    +            lines.append((content_style, text.ljust(inner_width)))
    +            lines.append((border_style, " │\n"))
    +
    +        def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
    +            lines.append((border_style, "│" + (" " * box_width) + "│\n"))
    +
    +        preview_lines = []
    +        for line in detail.splitlines():
    +            preview_lines.extend(_wrap_panel_text(line, 72))
    +        for idx, (_value, label, desc) in enumerate(choices):
    +            marker = "❯" if idx == selected else " "
    +            preview_lines.extend(_wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", 72, subsequent_indent="    "))
    +        preview_lines.append("Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.")
    +
    +        box_width = _panel_box_width(title, preview_lines)
    +        inner_text_width = max(8, box_width - 2)
    +        detail_wrapped = []
    +        for line in detail.splitlines():
    +            detail_wrapped.extend(_wrap_panel_text(line, inner_text_width))
    +        choice_wrapped: list[tuple[int, str]] = []
    +        for idx, (_value, label, desc) in enumerate(choices):
    +            marker = "❯" if idx == selected else " "
    +            for wrapped in _wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", inner_text_width, subsequent_indent="    "):
    +                choice_wrapped.append((idx, wrapped))
    +
    +        term_rows = shutil.get_terminal_size((100, 24)).lines
    +        reserved_below = 6
    +        chrome_full = 6
    +        available = max(0, term_rows - reserved_below)
    +        max_detail_rows = max(1, available - chrome_full - len(choice_wrapped))
    +        max_detail_rows = min(max_detail_rows, 8)
    +        if len(detail_wrapped) > max_detail_rows:
    +            keep = max(1, max_detail_rows - 1)
    +            detail_wrapped = detail_wrapped[:keep] + ["… (detail truncated)"]
    +
    +        lines = []
    +        lines.append(('class:approval-border', '╭' + ('─' * box_width) + '╮\n'))
    +        _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width)
    +        _append_blank_panel_line(lines, 'class:approval-border', box_width)
    +        for wrapped in detail_wrapped:
    +            _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width)
    +        _append_blank_panel_line(lines, 'class:approval-border', box_width)
    +        for idx, wrapped in choice_wrapped:
    +            style = 'class:approval-selected' if idx == selected else 'class:approval-choice'
    +            _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)
    +        _append_blank_panel_line(lines, 'class:approval-border', box_width)
    +        _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', 'Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.', box_width)
    +        lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n'))
    +        return lines
    +
         def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
             """Open prompt_toolkit-native /model picker modal."""
             self._capture_modal_input_snapshot()
    ```

    ```diff
    @@ -8577,30 +8767,24 @@ def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]
             if not confirm_required:
                 return "once"
     
    -        # Render warning + prompt — single-line composer prompt, mirrors
    -        # ``_confirm_and_reload_mcp``.
    -        print()
    -        print(f"⚠️  /{command} — destroys conversation state")
    -        print()
    -        for line in detail.splitlines():
    -            print(f"  {line}")
    -        print()
    -        print("  [1] Approve Once   — proceed this time only")
    -        print("  [2] Always Approve — proceed and silence this prompt permanently")
    -        print("  [3] Cancel         — keep current conversation")
    -        print()
    -        raw = self._prompt_text_input("Choice [1/2/3]: ")
    +        # Render a prompt_toolkit-native confirmation panel.  This keeps option
    +        # labels visible above the composer and avoids raw input()/EOF races with
    +        # the running TUI.
    +        choices = [
    +            ("once", "Approve Once", "proceed this time only"),
    +            ("always", "Always Approve", "proceed and silence this prompt permanently"),
    +            ("cancel", "Cancel", "keep current conversation"),
    +        ]
    +        raw = self._prompt_text_input_modal(
    +            title=f"⚠️  /{command} — destroys conversation state",
    +            detail=detail,
    +            choices=choices,
    +        )
             if raw is None:
                 print(f"🟡 /{command} cancelled (no input).")
                 return None
    -        choice_raw = raw.strip().lower()
    -        if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
    -            choice = "once"
    -        elif choice_raw in ("2", "always", "remember"):
    -            choice = "always"
    -        elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
    -            choice = "cancel"
    -        else:
    +        choice = self._normalize_slash_confirm_choice(raw, choices)
    +        if choice is None:
                 print(f"🟡 Unrecognized choice '{raw}'. /{command} cancelled.")
                 return None
     
    ```

    ```diff
    @@ -8645,32 +8829,28 @@ def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
                     self._reload_mcp()
                 return
     
    -        # Render warning + prompt.  Use a single-line prompt so the user
    -        # sees the warning as output and types a response into the composer.
    -        print()
    -        print("⚠️  /reload-mcp — Prompt cache invalidation warning")
    -        print()
    -        print("  Reloading MCP servers rebuilds the tool set for this session and")
    -        print("  invalidates the provider prompt cache.  The next message will")
    -        print("  re-send full input tokens (can be expensive on long-context or")
    -        print("  high-reasoning models).")
    -        print()
    -        print("  [1] Approve Once   — reload now")
    -        print("  [2] Always Approve — reload now and silence this prompt permanently")
    -        print("  [3] Cancel         — leave MCP tools unchanged")
    -        print()
    -        raw = self._prompt_text_input("Choice [1/2/3]: ")
    +        # Render warning + prompt.  Use the same prompt_toolkit-native composer
    +        # modal as destructive slash confirmations so choices stay visible.
    +        choices = [
    +            ("once", "Approve Once", "reload now"),
    +            ("always", "Always Approve", "reload now and silence this prompt permanently"),
    +            ("cancel", "Cancel", "leave MCP tools unchanged"),
    +        ]
    +        raw = self._prompt_text_input_modal(
    +            title="⚠️  /reload-mcp — Prompt cache invalidation warning",
    +            detail=(
    +                "Reloading MCP servers rebuilds the tool set for this session and\n"
    +                "invalidates the provider prompt cache. The next message will\n"
    +                "re-send full input tokens (can be expensive on long-context or\n"
    +                "high-reasoning models)."
    +            ),
    +            choices=choices,
    +        )
             if raw is None:
                 print("🟡 /reload-mcp cancelled (no input).")
                 return
    -        choice_raw = raw.strip().lower()
    -        if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
    -            choice = "once"
    -        elif choice_raw in ("2", "always", "remember"):
    -            choice = "always"
    -        elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
    -            choice = "cancel"
    -        else:
    +        choice = self._normalize_slash_confirm_choice(raw, choices)
    +        if choice is None:
                 print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.")
                 return
     
    ```

    ```diff
    @@ -10594,6 +10774,8 @@ def _state_fragment(style: str, icon: str, extra: str = ""):
                 return _state_fragment("class:sudo-prompt", "🔑")
             if self._approval_state:
                 return _state_fragment("class:prompt-working", "⚠")
    +        if getattr(self, "_slash_confirm_state", None):
    +            return _state_fragment("class:prompt-working", "⚠")
             if self._clarify_freetext:
                 return _state_fragment("class:clarify-selected", "✎")
             if self._clarify_state:
    ```

    ```diff
    @@ -10660,6 +10842,7 @@ def _build_tui_layout_children(
             sudo_widget,
             secret_widget,
             approval_widget,
    +        slash_confirm_widget=None,
             clarify_widget,
             model_picker_widget=None,
             spinner_widget=None,
    ```

    ```diff
    @@ -10684,6 +10867,7 @@ def _build_tui_layout_children(
                     sudo_widget,
                     secret_widget,
                     approval_widget,
    +                slash_confirm_widget,
                     clarify_widget,
                     model_picker_widget,
                     spinner_widget,
    ```

    ```diff
    @@ -10846,6 +11030,13 @@ def run(self):
             self._approval_deadline = 0
             self._approval_lock = threading.Lock()  # serialize concurrent approval prompts (delegation race fix)
     
    +        # Destructive slash-command confirmation state (/new, /clear, /undo).
    +        # These prompts are answered through the prompt_toolkit composer, not
    +        # raw input(), so the option labels stay visible and Enter does not EOF
    +        # the whole app.
    +        self._slash_confirm_state = None
    +        self._slash_confirm_deadline = 0
    +
             # Slash command loading state
             self._command_running = False
             self._command_status = ""
    ```

    ```diff
    @@ -10937,6 +11128,20 @@ def handle_enter(event):
                     event.app.invalidate()
                     return
     
    +            # --- Slash-command confirmation: submit typed or highlighted choice ---
    +            if self._slash_confirm_state:
    +                text = event.app.current_buffer.text.strip()
    +                choices = self._slash_confirm_state.get("choices") or []
    +                choice = self._normalize_slash_confirm_choice(text, choices) if text else None
    +                if choice is None:
    +                    selected = self._slash_confirm_state.get("selected", 0)
    +                    if 0 <= selected < len(choices):
    +                        choice = choices[selected][0]
    +                self._submit_slash_confirm_response(choice or "cancel")
    +                event.app.current_buffer.reset()
    +                event.app.invalidate()
    +                return
    +
                 # --- /model picker modal ---
                 if self._model_picker_state:
                     try:
    ```

    ```diff
    @@ -11197,6 +11402,20 @@ def approval_down(event):
                     self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1)
                     event.app.invalidate()
     
    +        # --- Slash-command confirmation: arrow-key navigation ---
    +        @kb.add('up', filter=Condition(lambda: bool(self._slash_confirm_state)))
    +        def slash_confirm_up(event):
    +            if self._slash_confirm_state:
    +                self._slash_confirm_state["selected"] = max(0, self._slash_confirm_state.get("selected", 0) - 1)
    +                event.app.invalidate()
    +
    +        @kb.add('down', filter=Condition(lambda: bool(self._slash_confirm_state)))
    +        def slash_confirm_down(event):
    +            if self._slash_confirm_state:
    +                max_idx = len(self._slash_confirm_state.get("choices") or []) - 1
    +                self._slash_confirm_state["selected"] = min(max_idx, self._slash_confirm_state.get("selected", 0) + 1)
    +                event.app.invalidate()
    +
             # --- /model picker: arrow-key navigation ---
             @kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))
             def model_picker_up(event):
    ```

    ```diff
    @@ -11237,12 +11456,26 @@ def handler(event):
                 _idx = 9 if _num == 0 else _num - 1
                 kb.add(str(_num), filter=Condition(lambda: bool(self._approval_state)))(_make_approval_number_handler(_idx))
     
    +        # Number keys for quick slash-confirm selection (1-9, 0 for 10th item)
    +        def _make_slash_confirm_number_handler(idx):
    +            def handler(event):
    +                if self._slash_confirm_state and idx < len(self._slash_confirm_state.get("choices") or []):
    +                    choice = self._slash_confirm_state["choices"][idx][0]
    +                    self._submit_slash_confirm_response(choice)
    +                    event.app.current_buffer.reset()
    +                    event.app.invalidate()
    +            return handler
    +
    +        for _num in range(10):
    +            _idx = 9 if _num == 0 else _num - 1
    +            kb.add(str(_num), filter=Condition(lambda: bool(self._slash_confirm_state)))(_make_slash_confirm_number_handler(_idx))
    +
             # --- History navigation: up/down browse history in normal input mode ---
             # The TextArea is multiline, so by default up/down only move the cursor.
             # Buffer.auto_up/auto_down handle both: cursor movement when multi-line,
             # history browsing when on the first/last line (or single-line input).
             _normal_input = Condition(
    -            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
    +            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
             )
     
             @kb.add('up', filter=_normal_input)
    ```

    ```diff
    @@ -11318,6 +11551,13 @@ def handle_ctrl_c(event):
                     event.app.invalidate()
                     return
     
    +            # Cancel slash confirmation prompt
    +            if self._slash_confirm_state:
    +                self._submit_slash_confirm_response("cancel")
    +                event.app.current_buffer.reset()
    +                event.app.invalidate()
    +                return
    +
                 # Cancel /model picker
                 if self._model_picker_state:
                     self._close_model_picker()
    ```

    ```diff
    @@ -11412,6 +11652,13 @@ def handle_ctrl_q(event):
                     event.app.invalidate()
                     return
     
    +            # Cancel slash confirmation prompt
    +            if self._slash_confirm_state:
    +                self._submit_slash_confirm_response("cancel")
    +                event.app.current_buffer.reset()
    +                event.app.invalidate()
    +                return
    +
                 # Cancel /model picker
                 if self._model_picker_state:
                     self._close_model_picker()
    ```

    ```diff
    @@ -11460,7 +11707,7 @@ def handle_ctrl_d(event):
                     event.app.exit()
     
             _modal_prompt_active = Condition(
    -            lambda: bool(self._secret_state or self._sudo_state)
    +            lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state)
             )
     
             @kb.add('escape', filter=_modal_prompt_active, eager=True)
    ```

    ```diff
    @@ -11476,6 +11723,11 @@ def handle_escape_modal(event):
                     self._sudo_state = None
                     event.app.invalidate()
                     return
    +            if self._slash_confirm_state:
    +                self._submit_slash_confirm_response("cancel")
    +                event.app.current_buffer.reset()
    +                event.app.invalidate()
    +                return
     
             @kb.add('c-z')
             def handle_ctrl_z(event):
    ```

    ```diff
    @@ -11558,7 +11810,7 @@ def handle_voice_record(event):
                     # Guard: don't START recording during agent run or interactive prompts
                     if cli_ref._agent_running:
                         return
    -                if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state:
    +                if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state or cli_ref._slash_confirm_state:
                         return
                     # Guard: don't start while a previous stop/transcribe cycle is
                     # still running — recorder.stop() holds AudioRecorder._lock and
    ```

    ```diff
    @@ -11845,6 +12097,8 @@ def _get_placeholder():
                     return "type secret (hidden), Enter to submit · ESC to skip"
                 if cli_ref._approval_state:
                     return ""
    +            if cli_ref._slash_confirm_state:
    +                return "type 1/2/3, or use ↑/↓ then Enter"
                 if cli_ref._clarify_freetext:
                     return "type your answer here and press Enter"
                 if cli_ref._clarify_state:
    ```

    ```diff
    @@ -11887,6 +12141,13 @@ def get_hint_text():
                         ('class:clarify-countdown', f'  ({remaining}s)'),
                     ]
     
    +            if cli_ref._slash_confirm_state:
    +                remaining = max(0, int(cli_ref._slash_confirm_deadline - time.monotonic()))
    +                return [
    +                    ('class:hint', '  type 1/2/3, or ↑/↓ to select, Enter to confirm'),
    +                    ('class:clarify-countdown', f'  ({remaining}s)'),
    +                ]
    +
                 if cli_ref._clarify_state:
                     remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic()))
                     countdown = f'  ({remaining}s)' if cli_ref._clarify_deadline else ''
    ```

    ```diff
    @@ -11909,7 +12170,7 @@ def get_hint_text():
                 return []
     
             def get_hint_height():
    -            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:
    +            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:
                     return 1
                 # Keep a spacer while the agent runs on roomy terminals, but reclaim
                 # the row on narrow/mobile screens where every line matters.
    ```

    ```diff
    @@ -12213,6 +12474,17 @@ def _get_approval_display():
                 filter=Condition(lambda: cli_ref._approval_state is not None),
             )
     
    +        def _get_slash_confirm_display():
    +            return cli_ref._get_slash_confirm_display_fragments()
    +
    +        slash_confirm_widget = ConditionalContainer(
    +            Window(
    +                FormattedTextControl(_get_slash_confirm_display),
    +                wrap_lines=True,
    +            ),
    +            filter=Condition(lambda: cli_ref._slash_confirm_state is not None),
    +        )
    +
             # --- /model picker: display widget ---
             def _get_model_picker_display():
                 state = cli_ref._model_picker_state
    ```

    ```diff
    @@ -12358,6 +12630,7 @@ def _get_voice_status():
                         sudo_widget=sudo_widget,
                         secret_widget=[redacted:secret-assignment]
                         approval_widget=approval_widget,
    +                    slash_confirm_widget=slash_confirm_widget,
                         clarify_widget=clarify_widget,
                         model_picker_widget=model_picker_widget,
                         spinner_widget=spinner_widget,
    ```
  - `scripts/release.py`

    ```diff
    @@ -51,6 +51,7 @@
         "[redacted:email]": "teknium1",
         "[redacted:email]": "thelumiereguy",
         "[redacted:email]": "counterposition",
    +    "[redacted:email]": "zhengyn0001",
         "[redacted:email]": "curiouscleo",
         "[redacted:email]": "hrkzogw",
         "[redacted:email]": "dandacompany",
    ```
  - `tests/cli/test_destructive_slash_confirm.py`

    ```diff
    @@ -6,6 +6,7 @@
     
     from __future__ import annotations
     
    +import queue
     from types import SimpleNamespace
     from unittest.mock import patch
     
    ```

    ```diff
    @@ -17,10 +18,17 @@ def _bound(fn, instance):
     
     def _make_self(prompt_response):
         """Build a minimal stand-in 'self' for _confirm_destructive_slash."""
    -    return SimpleNamespace(
    +    from cli import HermesCLI
    +
    +    self_ = SimpleNamespace(
             _app=None,
             _prompt_text_input=lambda _prompt: prompt_response,
    +        _prompt_text_input_modal=lambda **_kw: prompt_response,
    +    )
    +    self_._normalize_slash_confirm_choice = _bound(
    +        HermesCLI._normalize_slash_confirm_choice, self_,
         )
    +    return self_
     
     
     def test_gate_off_returns_once_without_prompting():
    ```

    ```diff
    @@ -117,7 +125,6 @@ def test_gate_on_choice_always_persists_and_returns_always():
         self_ = _make_self(prompt_response="2")
     
         saves = []
    -
         def _fake_save(key, value):
             saves.append((key, value))
             return True
    ```

    ```diff
    @@ -150,3 +157,55 @@ def test_gate_default_true_when_config_missing():
         # treated as on despite the config error.  If the gate had been off
         # this would have returned 'once' without consulting the prompt.
         assert result is None
    +
    +
    +def test_slash_confirm_modal_number_selection_submits_without_raw_input():
    +    """Pressing 2 in the TUI modal should resolve to Always Approve directly."""
    +    from cli import HermesCLI
    +
    +    q = queue.Queue()
    +    self_ = SimpleNamespace(
    +        _slash_confirm_state={
    +            "choices": [
    +                ("once", "Approve Once", "proceed once"),
    +                ("always", "Always Approve", "persist opt-out"),
    +                ("cancel", "Cancel", "abort"),
    +            ],
    +            "selected": 0,
    +            "response_queue": q,
    +        },
    +        _slash_confirm_deadline=123,
    +        _invalidate=lambda: None,
    +    )
    +
    +    _bound(HermesCLI._submit_slash_confirm_response, self_)("always")
    +
    +    assert q.get_nowait() == "always"
    +    assert self_._slash_confirm_state is None
    +    assert self_._slash_confirm_deadline == 0
    +
    +
    +def test_slash_confirm_display_fragments_include_choice_mapping():
    +    """The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'."""
    +    from cli import HermesCLI
    +
    +    self_ = SimpleNamespace(
    +        _slash_confirm_state={
    +            "title": "⚠️  /new — destroys conversation state",
    +            "detail": "This starts a fresh session.",
    +            "choices": [
    +                ("once", "Approve Once", "proceed once"),
    +                ("always", "Always Approve", "persist opt-out"),
    +                ("cancel", "Cancel", "abort"),
    +            ],
    +            "selected": 1,
    +        },
    +    )
    +
    +    fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)()
    +    rendered = "".join(fragment for _style, fragment in fragments)
    +
    +    assert "[1] Approve Once" in rendered
    +    assert "[2] Always Approve" in rendered
    +    assert "[3] Cancel" in rendered
    +    assert "Type 1/2/3" in rendered
    ```
  - `tests/cli/test_prompt_text_input_thread_safety.py`

    ```diff
    @@ -1,15 +1,9 @@
     """Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.
     
    -Slash commands (``/clear``, ``/new``, ``/undo``, ``/reload-mcp``) are dispatched
    -from the ``process_loop`` daemon thread.  ``prompt_toolkit.run_in_terminal``
    -returns a coroutine that only the main-thread event loop can drive; calling it
    -from a daemon thread orphans the coroutine, ``_ask`` never runs, and user
    -keystrokes leak into the composer instead of the confirmation prompt
    -(see issue #23185).
    -
    -The fix mirrors ``_run_curses_picker``: when off the main thread, fall back to
    -a direct ``input()`` call so the prompt actually renders and consumes
    -keystrokes.
    +Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI.
    +The normal slash confirmations now use a prompt_toolkit-native modal, but
    +``_prompt_text_input`` remains as a fallback for non-interactive calls and edge
    +cases.
     """
     
     import threading
    ```

    ```diff
    @@ -17,7 +11,7 @@
     
     
     def _make_cli():
    -    """Minimal HermesCLI shell exposing ``_prompt_text_input``."""
    +    """Minimal HermesCLI shell exposing prompt fallback helpers."""
         import cli as cli_mod
     
         obj = object.__new__(cli_mod.HermesCLI)
    ```

    ```diff
    @@ -33,7 +27,7 @@ def test_main_thread_uses_run_in_terminal(self):
     
             with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
                  patch("builtins.input", return_value="2"):
    -            result = cli._prompt_text_input("Choice: ")
    +            cli._prompt_text_input("Choice: ")
     
             # run_in_terminal was invoked; the _ask closure passed to it would
             # call input() when driven by the event loop.  We assert dispatch path,
    ```

    ```diff
    @@ -43,10 +37,8 @@ def test_main_thread_uses_run_in_terminal(self):
         def test_background_thread_falls_back_to_direct_input(self):
             """On a daemon thread, skip run_in_terminal and call input() directly.
     
    -        This is the bug from issue #23185: process_loop dispatches slash
    -        commands on a daemon thread, so run_in_terminal's coroutine is
    -        orphaned.  The fallback must drive input() itself so user keystrokes
    -        don't leak into the agent buffer.
    +        This preserves the fallback for any prompt that still runs off the main
    +        UI thread: run_in_terminal's coroutine would otherwise be orphaned.
             """
             cli = _make_cli()
             captured = {}
    ```
- **#23173: fix(tui): route confirmation dialog through _cprint for TUI rendering**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#23176: fix(tui): render destructive slash confirmations via cprint**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#23907`, `#23907`
- **#23290: fix(cli): render /clear confirmation dialog via _cprint in TUI mode**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#23341: fix(cli): route /clear confirmation dialog through TUI renderer**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#23173`, `#23155`

## Fix status

- Merged to main in PR #23907 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-11`.
- Subsystem: `hermes_cli`
