TUI confirmation dialog rendering fix attempts

NousResearch/hermes-agent · cluster 23155 · hermes cli

Data as of

Nothing open on GitHub. Fix merged in #23907. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #23907. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #23907 fix(cli): use prompt_toolkit modal for destructive slash confirmations

    merged to main

    files: cli.py, scripts/release.py, tests/cli/test_destructive_slash_confirm.py, tests/cli/test_prompt_text_input_thread_safety.py

    Show the diff
    • cli.py
      @@ -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
      @@ -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)
      @@ -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()
      @@ -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
       
      @@ -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
       
      @@ -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:
      @@ -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,
      @@ -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,
      @@ -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 = ""
      @@ -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:
      @@ -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):
      @@ -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)
      @@ -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()
      @@ -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()
      @@ -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)
      @@ -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):
      @@ -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
      @@ -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:
      @@ -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 ''
      @@ -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.
      @@ -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
      @@ -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
      @@ -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
      @@ -6,6 +6,7 @@
       
       from __future__ import annotations
       
      +import queue
       from types import SimpleNamespace
       from unittest.mock import patch
       
      @@ -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():
      @@ -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
      @@ -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
      @@ -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
      @@ -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)
      @@ -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,
      @@ -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 = {}

4 PRs closed without merging

From the close comments: duplicate 2 · stale or abandoned 1 · superseded by another PR 1

  • #23173 fix(tui): route confirmation dialog through _cprint for TUI rendering · duplicate
  • #23176 fix(tui): render destructive slash confirmations via cprint · superseded by another PR → #23907
  • #23290 fix(cli): render /clear confirmation dialog via _cprint in TUI mode · stale or abandoned
  • #23341 fix(cli): route /clear confirmation dialog through TUI renderer · duplicate → #23173, #23155
Closed issues · 1
  • Closed#23155fix: /clear confirmation dialog renders as raw text in TUI mode
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.33: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#23907
Closed issues
1
Attempts
4 PRs closed without merging
Fix link checked
11 May 2026

For your agent

The same facts, typed.