CLI slash commands freezing and blocking issues
NousResearch/hermes-agent · cluster 21348 · hermes cli
Data as of
3 open items. Fix merged in #23454. Check it covers your case.
The short answer
- Known?
- Yes. 47 related items grouped here, 20 issues already closed.
- Fix event
- A fix was merged to main in PR #23454. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #23907, #31704, #33241. That is separate from the fix event. - Still open
- 2 issues and 1 PR.
Next step: read #23454, then check whether your report is one of the 2 still open.
Read #23454Pick up a Lab taskMultiple bugs and fixes related to slash commands (/new, /clear, /reset, /reload-mcp) causing terminal freezes, deadlocks, and blocking behavior, particularly around threading, event loop scheduling, and prompt handling across platforms.
Open issues2
Open PRs1
- PR#39446fix(cli): keep /reload-mcp off the input thread
Fixes on record
-
#23454 fix(cli): drive _prompt_text_input directly when off main thread (#23185)
files:
cli.py,tests/cli/test_prompt_text_input_thread_safety.pyShow the diff
-
cli.py@@ -6008,7 +6008,17 @@ def _pick(): return result[0] def _prompt_text_input(self, prompt_text: str) -> str | None: - """Prompt for free-text input safely inside or outside prompt_toolkit.""" + """Prompt for free-text input safely inside or outside prompt_toolkit. + + Mirrors the thread-aware guard in ``_run_curses_picker``: ``run_in_terminal`` + returns a coroutine that must be awaited by the prompt_toolkit event loop, + which only exists on the main thread. Slash commands are dispatched from + the ``process_loop`` daemon thread (see issue #23185), so calling + ``run_in_terminal`` from there orphans the coroutine — ``_ask`` never runs, + and user keystrokes leak into the composer instead. Fall back to a direct + ``input()`` when we're off the main thread. + """ + import threading result = [None] def _ask(): @@ -6017,13 +6027,23 @@ def _ask(): except (KeyboardInterrupt, EOFError): pass - if self._app: + in_main_thread = threading.current_thread() is threading.main_thread() + + if self._app and in_main_thread: from prompt_toolkit.application import run_in_terminal was_visible = self._status_bar_visible self._status_bar_visible = False self._app.invalidate() try: run_in_terminal(_ask) + except Exception: + # WSL / Warp / certain terminal emulators silently drop the + # scheduled coroutine. Fall back to a direct input() so the + # user's keystrokes don't leak into the agent buffer. + try: + _ask() + except Exception: + pass finally: self._status_bar_visible = was_visible self._app.invalidate() -
tests/cli/test_prompt_text_input_thread_safety.py@@ -0,0 +1,109 @@ +"""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. +""" + +import threading +from unittest.mock import MagicMock, patch + + +def _make_cli(): + """Minimal HermesCLI shell exposing ``_prompt_text_input``.""" + import cli as cli_mod + + obj = object.__new__(cli_mod.HermesCLI) + obj._app = MagicMock() + obj._status_bar_visible = True + return obj + + +class TestPromptTextInputThreadSafety: + def test_main_thread_uses_run_in_terminal(self): + """On the main thread with an active app, route through run_in_terminal.""" + cli = _make_cli() + + with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \ + patch("builtins.input", return_value="2"): + result = 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, + # not the orphaned-coroutine result. + assert mock_rit.called + + 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. + """ + cli = _make_cli() + captured = {} + + def fake_input(prompt): + captured["prompt"] = prompt + return "1" + + result_holder = {} + + def run_on_daemon(): + with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \ + patch("builtins.input", side_effect=fake_input): + result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ") + result_holder["rit_called"] = mock_rit.called + + t = threading.Thread(target=run_on_daemon, daemon=True) + t.start() + t.join(timeout=2.0) + assert not t.is_alive(), "daemon thread hung — input() was not driven" + + # run_in_terminal was bypassed entirely on the background thread. + assert result_holder["rit_called"] is False + # input() was invoked with the prompt and its return value was captured. + assert captured.get("prompt") == "Choice [1/2/3]: " + assert result_holder["value"] == "1" + + def test_no_app_uses_direct_input(self): + """Without an active prompt_toolkit app, always call input() directly.""" + cli = _make_cli() + cli._app = None + + with patch("builtins.input", return_value="cancel") as mock_input: + result = cli._prompt_text_input("Choice: ") + + assert mock_input.called + assert result == "cancel" + + def test_run_in_terminal_exception_falls_back(self): + """If run_in_terminal raises (WSL / Warp edge cases), fall back to input().""" + cli = _make_cli() + + with patch( + "prompt_toolkit.application.run_in_terminal", + side_effect=RuntimeError("event loop dropped the coroutine"), + ), patch("builtins.input", return_value="3") as mock_input: + result = cli._prompt_text_input("Choice: ") + + assert mock_input.called + assert result == "3" + + def test_eof_returns_none(self): + """EOFError from input() yields None, not an unhandled exception.""" + cli = _make_cli() + cli._app = None + + with patch("builtins.input", side_effect=EOFError()): + result = cli._prompt_text_input("Choice: ") + + assert result is None
-
-
#23907 fix(cli): use prompt_toolkit modal for destructive slash confirmations
files:
cli.py,scripts/release.py,tests/cli/test_destructive_slash_confirm.py,tests/cli/test_prompt_text_input_thread_safety.pyShow 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 = {}
-
-
#31704 fix(cli): add inline --yes/now skip for destructive slash commands (#30768)
files:
cli.py,tests/cli/test_destructive_slash_confirm.py,tests/cli/test_destructive_slash_inline_skip_e2e.py,website/docs/reference/slash-commands.mdShow the diff
-
cli.py@@ -8101,6 +8101,7 @@ def process_command(self, command: str) -> bool: "clear", "This clears the screen and starts a new session.\n" "The current conversation history will be discarded.", + cmd_original=cmd_original, ) is None: return self.new_session(silent=True) @@ -8225,12 +8226,16 @@ def process_command(self, command: str) -> bool: if not self._handle_handoff_command(cmd_original): return False elif canonical == "new": - parts = cmd_original.split(maxsplit=1) - title = parts[1].strip() if len(parts) > 1 else None + # Strip inline-skip tokens (now/--yes/-y) before deriving the title + # so "/new now My Session" yields title="My Session" instead of + # title="now My Session". See _split_destructive_skip. + _new_args, _ = self._split_destructive_skip(cmd_original) + title = _new_args.strip() or None if self._confirm_destructive_slash( "new", "This starts a fresh session.\n" "The current conversation history will be discarded.", + cmd_original=cmd_original, ) is None: return self.new_session(title=title) @@ -8257,6 +8262,7 @@ def process_command(self, command: str) -> bool: if self._confirm_destructive_slash( "undo", "This removes the last user/assistant exchange from history.", + cmd_original=cmd_original, ) is None: return self.undo_last() @@ -9908,7 +9914,49 @@ def _check_config_mcp_changes(self) -> None: if _reload_thread.is_alive(): print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.") - def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]: + # Inline-skip tokens that bypass the destructive-slash confirmation modal. + # Matches the escape-hatch pattern users on broken modal platforms + # (currently native Windows PowerShell — issue #30768) need to self-serve + # without having to flip approvals.destructive_slash_confirm in config. + _DESTRUCTIVE_SKIP_TOKENS = [redacted:secret-assignment]"now", "--yes", "-y"}) + + @classmethod + def _split_destructive_skip(cls, cmd_text: Optional[str]) -> tuple[str, bool]: + """Split inline-skip tokens out of a destructive slash command. + + Returns ``(remainder, skip)`` where ``remainder`` is the original + text with the command word and any recognized skip tokens removed, + and ``skip`` is True iff at least one skip token was found. + + Examples: + "/reset now" -> ("", True) + "/reset --yes My title" -> ("My title", True) + "/new My title" -> ("My title", False) + "/clear" -> ("", False) + """ + if not cmd_text: + return "", False + tokens = [redacted:secret-assignment] + if not tokens: + return "", False + # Drop leading "/cmd" word — callers pass the full command text. + if tokens[0].startswith("/"): + tokens = [redacted:secret-assignment] + skip = False + kept: list[str] = [] + for tok in tokens: + if tok.lower() in cls._DESTRUCTIVE_SKIP_TOKENS: + skip = True + continue + kept.append(tok) + return " ".join(kept), skip + + def _confirm_destructive_slash( + self, + command: str, + detail: str, + cmd_original: Optional[str] = None, + ) -> Optional[str]: """Prompt the user to confirm a destructive session slash command. Used by ``/clear``, ``/new``/``/reset``, and ``/undo`` before they @@ -9924,9 +9972,24 @@ def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str] gate is off the function returns ``"once"`` immediately without prompting. + Inline-skip: if ``cmd_original`` contains ``now``, ``--yes``, or + ``-y`` as an argument (e.g. ``/reset now``, ``/new --yes My title``), + the modal is bypassed and ``"once"`` is returned immediately. This is + an escape hatch for platforms where the prompt_toolkit modal hangs + (issue #30768 — native Windows PowerShell). Callers are responsible + for stripping the skip tokens from any remaining argument parsing + (see :meth:`_split_destructive_skip`). + Returns ``"once"``, ``"always"``, or ``None`` (cancelled). Callers proceed with the destructive action when the result is non-None. """ + # Inline-skip escape hatch — works regardless of platform/modal state. + # See class-level _DESTRUCTIVE_SKIP_TOKENS for the accepted tokens. + if cmd_original: + _, _skip = self._split_destructive_skip(cmd_original) + if _skip: + return "once" + # Gate check — respects prior "Always Approve" clicks. try: cfg = load_cli_config() -
tests/cli/test_destructive_slash_confirm.py@@ -209,3 +209,123 @@ def test_slash_confirm_display_fragments_include_choice_mapping(): assert "[2] Always Approve" in rendered assert "[3] Cancel" in rendered assert "Type 1/2/3" in rendered + + +# --------------------------------------------------------------------------- +# Inline-skip escape hatch (issue #30768) +# +# Users on platforms where the prompt_toolkit modal doesn't dispatch keys +# (currently native Windows PowerShell) need a way to bypass the confirmation +# without flipping the config gate. ``/reset now``, ``/new --yes``, ``/clear +# -y`` all skip the modal and return "once" immediately. +# --------------------------------------------------------------------------- + + +def test_split_destructive_skip_recognized_tokens(): + """``now``, ``--yes``, and ``-y`` are recognized as skip tokens.""" + from cli import HermesCLI + + assert HermesCLI._split_destructive_skip("/reset now") == ("", True) + assert HermesCLI._split_destructive_skip("/clear --yes") == ("", True) + assert HermesCLI._split_destructive_skip("/undo -y") == ("", True) + + +def test_split_destructive_skip_strips_command_word(): + """Leading ``/cmd`` token is stripped; remaining args survive.""" + from cli import HermesCLI + + assert HermesCLI._split_destructive_skip("/new My title") == ("My title", False) + assert HermesCLI._split_destructive_skip("/new --yes My title") == ("My title", True) + + +def test_split_destructive_skip_case_insensitive(): + """Token matching is case-insensitive but not a substring match.""" + from cli import HermesCLI + + assert HermesCLI._split_destructive_skip("/new NOW") == ("", True) + # Substring match must NOT trigger — "Now-Title" is a literal title token. + assert HermesCLI._split_destructive_skip("/new Now-Title") == ("Now-Title", False) + + +def test_split_destructive_skip_handles_empty_and_none(): + """Defensive against missing/empty input.""" + from cli import HermesCLI + + assert HermesCLI._split_destructive_skip(None) == ("", False) + assert HermesCLI._split_destructive_skip("") == ("", False) + assert HermesCLI._split_destructive_skip(" ") == ("", False) + + +def test_confirm_destructive_slash_now_skips_modal(): + """``/reset now`` skips the modal even when the gate is on.""" + from cli import HermesCLI + + # Build a prompt stub that fails the test if invoked — proving the modal + # was never reached. + def _explode(**_kw): + raise AssertionError("modal must not be invoked when inline-skip present") + + self_ = SimpleNamespace( + _app=None, + _prompt_text_input_modal=_explode, + ) + self_._normalize_slash_confirm_choice = _bound( + HermesCLI._normalize_slash_confirm_choice, self_, + ) + self_._split_destructive_skip = HermesCLI._split_destructive_skip # classmethod + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + result = _bound(HermesCLI._confirm_destructive_slash, self_)( + "new", "detail", cmd_original="/reset now", + ) + + assert result == "once" + + +def test_confirm_destructive_slash_yes_flag_skips_modal(): + """``--yes`` flag is equivalent to ``now``.""" + from cli import HermesCLI + + def _explode(**_kw): + raise AssertionError("modal must not be invoked when --yes present") + + self_ = SimpleNamespace( + _app=None, + _prompt_text_input_modal=_explode, + ) + self_._normalize_slash_confirm_choice = _bound( + HermesCLI._normalize_slash_confirm_choice, self_, + ) + self_._split_destructive_skip = HermesCLI._split_destructive_skip + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + result = _bound(HermesCLI._confirm_destructive_slash, self_)( + "new", "detail", cmd_original="/new --yes My Session", + ) + + assert result == "once" + + +def test_confirm_destructive_slash_no_skip_token_still_prompts(): + """Without a skip token the gate-on path still consults the modal.""" + from cli import HermesCLI + + self_ = _make_self(prompt_response="3") # cancel + self_._split_destructive_skip = HermesCLI._split_destructive_skip + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + result = _bound(HermesCLI._confirm_destructive_slash, self_)( + "new", "detail", cmd_original="/new My Session", + ) + + # Prompt was reached and returned cancel → None. + assert result is None -
tests/cli/test_destructive_slash_inline_skip_e2e.py@@ -0,0 +1,129 @@ +"""End-to-end integration test for the destructive-slash inline-skip path. + +Drives ``HermesCLI.process_command("/reset now")`` against a minimal stand-in +and verifies: + +1. ``new_session`` was invoked (the command actually ran) +2. ``_prompt_text_input_modal`` was NOT invoked (modal bypassed) +3. The skip token did not leak into the session title + +This is the regression test for issue #30768 — the inline-skip escape hatch +must work without ever touching the modal, on every platform. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + + +def _make_cli_stub(): + """Build a minimal HermesCLI-shaped object that can run ``process_command`` + for the destructive-slash branches without spinning up a real TUI.""" + from cli import HermesCLI + + new_session_calls = [] + + def _capture_new_session(self_, title=None, silent=False): + new_session_calls.append({"title": title, "silent": silent}) + + self_ = SimpleNamespace( + _app=None, + _prompt_text_input_modal=lambda **_kw: (_ for _ in ()).throw( + AssertionError("modal must not be invoked when inline-skip token present") + ), + new_session=lambda **kw: _capture_new_session(self_, **kw), + # Stub out side-effects the destructive-slash branches reach for. + console=SimpleNamespace(clear=lambda: None), + compact=False, + model="stub-model", + session_id="stub-session", + enabled_toolsets=[], + _pending_title=None, + _session_db=None, + ) + # Bind the methods we need under test. + self_._split_destructive_skip = HermesCLI._split_destructive_skip + self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__( + self_, type(self_) + ) + self_.process_command = HermesCLI.process_command.__get__(self_, type(self_)) + return self_, new_session_calls + + +def test_reset_now_invokes_new_session_without_modal(): + """``/reset now`` runs ``new_session`` and never touches the modal.""" + self_, calls = _make_cli_stub() + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + self_.process_command("/reset now") + + assert calls, "new_session was never invoked" + # The /new branch passes title=None when there's no non-skip remainder. + assert calls[0]["title"] is None + + +def test_new_yes_with_title_preserves_title(): + """``/new --yes My Session`` runs ``new_session(title='My Session')``.""" + self_, calls = _make_cli_stub() + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + self_.process_command("/new --yes My Session") + + assert calls, "new_session was never invoked" + assert calls[0]["title"] == "My Session" + + +def test_new_without_skip_token_still_consults_modal(): + """``/new My Session`` (no skip token) must reach the modal. + + Sanity check that we haven't accidentally short-circuited the normal path. + """ + from cli import HermesCLI + + new_session_calls = [] + modal_calls = [] + + def _capture_new_session(self_, title=None, silent=False): + new_session_calls.append({"title": title, "silent": silent}) + + def _record_modal(**kw): + modal_calls.append(kw) + # Simulate user cancelling so new_session is not called. + return "3" + + self_ = SimpleNamespace( + _app=None, + _prompt_text_input_modal=_record_modal, + new_session=lambda **kw: _capture_new_session(self_, **kw), + console=SimpleNamespace(clear=lambda: None), + compact=False, + model="stub-model", + session_id="stub-session", + enabled_toolsets=[], + _pending_title=None, + _session_db=None, + ) + self_._split_destructive_skip = HermesCLI._split_destructive_skip + self_._normalize_slash_confirm_choice = HermesCLI._normalize_slash_confirm_choice.__get__( + self_, type(self_) + ) + self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__( + self_, type(self_) + ) + self_.process_command = HermesCLI.process_command.__get__(self_, type(self_)) + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + self_.process_command("/new My Session") + + assert modal_calls, "modal must be reached when no skip token is present" + assert not new_session_calls, "user cancelled — new_session must not run" -
website/docs/reference/slash-commands.md@@ -36,7 +36,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | Command | Description | |---------|-------------| -| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. | +| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. Append `now`, `--yes`, or `-y` to skip the confirmation modal — e.g. `/reset now`, `/new --yes my-experiment`. | | `/clear` | Clear screen and start a new session | | `/history` | Show conversation history | | `/save` | Save the current conversation | @@ -252,4 +252,6 @@ The CLI prompts before running slash commands that throw away unsaved session st For each of these the CLI opens a three-choice modal: **Approve Once** (proceed this time), **Always Approve** (proceed and persist `approvals.destructive_slash_confirm: false` so future destructive commands run without prompting), or **Cancel**. +**Inline skip:** append `now`, `--yes`, or `-y` to bypass the modal for a single invocation — e.g. `/reset now`, `/new --yes my-session`, `/clear -y`, `/undo -y`. Useful when the modal doesn't render correctly on your terminal (see [issue #30768](https://github.com/NousResearch/hermes-agent/issues/30768) for native Windows PowerShell) or when scripting against the CLI. + Set `approvals.destructive_slash_confirm: false` in `~/.hermes/config.yaml` to disable the prompts globally; set it back to `true` to re-enable. See [Security — Destructive slash command confirmation](../user-guide/security.md#dangerous-command-approval) for context.
-
-
#32013 fix(cli): prevent /reset and /new freeze on Windows by falling back to stdin prompt (#30768)
files:
cli.py,scripts/release.py,tests/cli/test_slash_confirm_windows.pyShow the diff
-
cli.py@@ -7034,7 +7034,28 @@ def _prompt_text_input_modal( 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. + + **Platform note (Windows dead-lock — issue #30768):** + The queue-based modal relies on prompt_toolkit key bindings receiving + keyboard events and calling ``_submit_slash_confirm_response``. On + Windows (PowerShell / Windows Terminal) the prompt_toolkit input + channel can become unresponsive when the modal is entered from the + ``process_loop`` daemon thread, causing a dead-lock: the user sees the + confirmation panel but keystrokes never reach the key bindings and the + ``response_queue.get()`` blocks until the 120-second timeout expires. + + To avoid this, we fall back to ``_prompt_text_input`` (a simple + ``input()``-based prompt) when any of these conditions hold: + + * ``sys.platform == "win32"`` — native Windows console (ConPTY / + win32_input) does not support the modal reliably. + * Called from a non-main thread — the prompt_toolkit event loop only + runs on the main thread; key bindings can't fire from a daemon + thread (same rationale as the ``_prompt_text_input`` thread guard + in PR #23454). + * ``self._app`` is not set — unit tests / non-interactive contexts. """ + import threading import time as _time if not choices: @@ -7045,6 +7066,20 @@ def _prompt_text_input_modal( if not getattr(self, "_app", None): return self._prompt_text_input("Choice [1/2/3]: ") + # On Windows the prompt_toolkit input channel can deadlock when the + # modal is entered from the process_loop daemon thread — keystrokes + # never reach the key bindings, so response_queue.get() blocks for + # the full timeout (issue #30768). Fall back to the simpler + # stdin-based prompt which works reliably on Windows. + if sys.platform == "win32": + return self._prompt_text_input("Choice [1/2/3]: ") + + # Mirror the thread-aware guard from _prompt_text_input (PR #23454): + # run_in_terminal and the modal queue both depend on the main-thread + # event loop. From a daemon thread the modal key bindings never fire. + if threading.current_thread() is not threading.main_thread(): + return self._prompt_text_input("Choice [1/2/3]: ") + response_queue = queue.Queue() self._capture_modal_input_snapshot() self._slash_confirm_state = { -
scripts/release.py@@ -1301,6 +1301,7 @@ "[redacted:email]": "SamuelZ12", # PR #7480 (show recap after in-session resume) "[redacted:email]": "daizhonggeng", # PR #9020 (numbered /resume selection) "[redacted:email]": "wanwan2qq", # PR #10215 (strip brackets/quotes from /resume; gateway session-ID lookup) + "[redacted:email]": "SimoKiihamaki", # PR #30773 (Windows /reset+/new freeze; stdin fallback for modal) } -
tests/cli/test_slash_confirm_windows.py@@ -0,0 +1,259 @@ +"""Regression tests for issue #30768: /reset and /new freeze on Windows. + +``_prompt_text_input_modal`` uses a queue-based modal that relies on +prompt_toolkit key bindings receiving keyboard events. On Windows the +prompt_toolkit input channel can deadlock when the modal is entered from +the ``process_loop`` daemon thread. The fix falls back to the simpler +``_prompt_text_input`` (stdin-based) prompt on Windows and non-main threads. + +These tests verify: +1. Windows detection triggers the stdin fallback +2. Non-main thread detection triggers the stdin fallback +3. macOS/Linux main-thread path still uses the modal (no regression) +4. No-app path still uses the stdin fallback (existing behavior) +5. Empty choices returns None (existing behavior) +""" + +import queue +import sys +import threading +import time +from unittest.mock import MagicMock, patch + +import pytest + + +def _make_cli(): + """Minimal HermesCLI shell exposing prompt/modal helpers.""" + import cli as cli_mod + + obj = object.__new__(cli_mod.HermesCLI) + obj._app = MagicMock() + obj._status_bar_visible = True + obj._last_invalidate = 0.0 + obj._modal_input_snapshot = None + obj._slash_confirm_state = None + obj._slash_confirm_deadline = 0 + return obj + + +# --------------------------------------------------------------------------- +# Sample choices used across tests +# --------------------------------------------------------------------------- +_SAMPLE_CHOICES = [ + ("once", "Approve Once", "proceed this time only"), + ("always", "Always Approve", "proceed and silence this prompt permanently"), + ("cancel", "Cancel", "keep current conversation"), +] + + +class TestModalWindowsFallback: + """Windows dead-lock regression tests for _prompt_text_input_modal.""" + + def test_windows_falls_back_to_stdin(self): + """On Windows, _prompt_text_input_modal should use _prompt_text_input.""" + cli = _make_cli() + + with patch.object(sys, "platform", "win32"), \ + patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin: + result = cli._prompt_text_input_modal( + title="⚠️ /new — destroys conversation state", + detail="This starts a fresh session.", + choices=_SAMPLE_CHOICES, + ) + + # The stdin-based fallback was used, not the modal queue path. + mock_stdin.assert_called_once_with("Choice [1/2/3]: ") + assert result == "1" + + def test_non_main_thread_falls_back_to_stdin(self): + """Off the main thread, _prompt_text_input_modal should use stdin fallback.""" + cli = _make_cli() + result_holder = {} + + def run_on_daemon(): + # Patch platform to "linux" so the Windows check doesn't short-circuit. + with patch.object(sys, "platform", "linux"), \ + patch.object(cli, "_prompt_text_input", return_value="2") as mock_stdin: + result_holder["result"] = cli._prompt_text_input_modal( + title="⚠️ /reset", + detail="This starts a fresh session.", + choices=_SAMPLE_CHOICES, + ) + result_holder["stdin_called"] = mock_stdin.called + + t = threading.Thread(target=run_on_daemon, daemon=True) + t.start() + t.join(timeout=2.0) + assert not t.is_alive(), "daemon thread hung — modal deadlocked" + assert result_holder["stdin_called"] is True + assert result_holder["result"] == "2" + + def test_main_thread_non_windows_uses_modal(self): + """On macOS/Linux main thread, the queue-based modal is still used.""" + cli = _make_cli() + + # We need to simulate the modal receiving a response. We'll patch + # the response_queue to immediately return a value. + with patch.object(sys, "platform", "darwin"), \ + patch.object(cli, "_capture_modal_input_snapshot"), \ + patch.object(cli, "_restore_modal_input_snapshot"), \ + patch.object(cli, "_invalidate"): + # Start the modal in a way that it will receive a response + # immediately via the queue. + original_queue = queue.Queue + original_time = time.monotonic + + def _fake_modal_flow(*args, **kwargs): + """Simulate the modal flow: set state, put response, return.""" + # We'll directly test that the modal path is entered by + # checking that _slash_confirm_state was set. + pass + + # Since we can't easily mock the internal queue, let's test + # that the modal path is entered by checking that + # _prompt_text_input was NOT called. + with patch.object(cli, "_prompt_text_input") as mock_stdin: + # Set up a response that will be put into the queue + # after the modal starts waiting. + def _submit_after_delay(): + time.sleep(0.2) + state = cli._slash_confirm_state + if state and "response_queue" in state: + state["response_queue"].put("once") + + submitter = threading.Thread(target=_submit_after_delay, daemon=True) + submitter.start() + + result = cli._prompt_text_input_modal( + title="⚠️ /new", + detail="This starts a fresh session.", + choices=_SAMPLE_CHOICES, + timeout=5, + ) + + submitter.join(timeout=2.0) + + # The stdin fallback should NOT have been called. + mock_stdin.assert_not_called() + # The result should be "once" from the simulated modal response. + assert result == "once" + + def test_no_app_falls_back_to_stdin(self): + """Without a prompt_toolkit app, always use stdin fallback.""" + cli = _make_cli() + cli._app = None + + with patch.object(cli, "_prompt_text_input", return_value="3") as mock_stdin: + result = cli._prompt_text_input_modal( + title="⚠️ /clear", + detail="This clears the screen.", + choices=_SAMPLE_CHOICES, + ) + + mock_stdin.assert_called_once_with("Choice [1/2/3]: ") + assert result == "3" + + def test_empty_choices_returns_none(self): + """Empty choices list should return None without prompting.""" + cli = _make_cli() + + with patch.object(cli, "_prompt_text_input") as mock_stdin: + result = cli._prompt_text_input_modal( + title="Test", + detail="Test", + choices=[], + ) + + mock_stdin.assert_not_called() + assert result is None + + def test_windows_fallback_does_not_set_modal_state(self): + """Verify Windows fallback doesn't leave _slash_confirm_state set.""" + cli = _make_cli() + + with patch.object(sys, "platform", "win32"), \ + patch.object(cli, "_prompt_text_input", return_value="1"): + cli._prompt_text_input_modal( + title="⚠️ /reset", + detail="This starts a fresh session.", + choices=_SAMPLE_CHOICES, + ) + + assert cli._slash_confirm_state is None + + def test_non_main_thread_fallback_does_not_set_modal_state(self): + """Verify daemon-thread fallback doesn't leave modal state set.""" + cli = _make_cli() + errors = [] + + def run_on_daemon(): + try: + with patch.object(sys, "platform", "linux"), \ + patch.object(cli, "_prompt_text_input", return_value="1"): + cli._prompt_text_input_modal( + title="⚠️ /new", + detail="This starts a fresh session.", + choices=_SAMPLE_CHOICES, + ) + if cli._slash_confirm_state is not None: + errors.append("_slash_confirm_state should be None") + except Exception as exc: + errors.append(str(exc)) + + t = threading.Thread(target=run_on_daemon, daemon=True) + t.start() + t.join(timeout=2.0) + assert not errors, f"unexpected errors: {errors}" + assert cli._slash_confirm_state is None + + +class TestConfirmDestructiveSlashWindows: + """Integration-level tests for _confirm_destructive_slash on Windows.""" + + def test_confirm_destructive_slash_bypasses_modal_on_windows(self): + """_confirm_destructive_slash should work on Windows via stdin fallback.""" + cli = _make_cli() + cli.model = "test-model" + cli._agent_running = False + cli._spinner_text = "" + cli._should_exit = False + cli._command_running = False + cli.session_id = "test-session" + cli._pending_tool_info = {} + cli._tool_start_time = 0.0 + cli._last_scrollback_tool = "" + + with patch.object(sys, "platform", "win32"), \ + patch.object(cli, "_prompt_text_input", return_value="1"), \ + patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}): + result = cli._confirm_destructive_slash( + "new", + "This starts a fresh session.\nThe current conversation history will be discarded.", + ) + + assert result == "once" + + def test_confirm_destructive_slash_cancelled_on_windows(self): + """Cancellation via stdin fallback works on Windows.""" + cli = _make_cli() + cli.model = "test-model" + cli._agent_running = False + cli._spinner_text = "" + cli._should_exit = False + cli._command_running = False + cli.session_id = "test-session" + cli._pending_tool_info = {} + cli._tool_start_time = 0.0 + cli._last_scrollback_tool = "" + + with patch.object(sys, "platform", "win32"), \ + patch.object(cli, "_prompt_text_input", return_value="3"), \ + patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}): + result = cli._confirm_destructive_slash( + "reset", + "This starts a fresh session.\nThe current conversation history will be discarded.", + ) + + # Choice "3" normalizes to "cancel", which returns None. + assert result is None
-
-
#33241 fix(cli): keep destructive slash modal on Linux/macOS daemon thread
files:
cli.py,tests/cli/test_slash_confirm_windows.pyShow the diff
-
cli.py@@ -7154,11 +7154,13 @@ def _prompt_text_input_modal( * ``sys.platform == "win32"`` — native Windows console (ConPTY / win32_input) does not support the modal reliably. - * Called from a non-main thread — the prompt_toolkit event loop only - runs on the main thread; key bindings can't fire from a daemon - thread (same rationale as the ``_prompt_text_input`` thread guard - in PR #23454). * ``self._app`` is not set — unit tests / non-interactive contexts. + + On non-Windows platforms the modal itself is still safe from the + ``process_loop`` daemon thread as long as the main-thread event loop + owns the prompt_toolkit buffer mutations. When we are off the main + thread, schedule the modal snapshot / restore work on ``self._app.loop`` + via ``call_soon_threadsafe`` and keep the queue-based response path. """ import threading import time as _time @@ -7179,33 +7181,62 @@ def _prompt_text_input_modal( if sys.platform == "win32": return self._prompt_text_input("Choice [1/2/3]: ") - # Mirror the thread-aware guard from _prompt_text_input (PR #23454): - # run_in_terminal and the modal queue both depend on the main-thread - # event loop. From a daemon thread the modal key bindings never fire. - if threading.current_thread() is not threading.main_thread(): + try: + app_loop = self._app.loop + except Exception: + app_loop = None + + in_main_thread = threading.current_thread() is threading.main_thread() + if not in_main_thread and app_loop is 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() + + def _setup_modal() -> None: + 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() + + def _teardown_modal() -> None: + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + self._restore_modal_input_snapshot() + self._invalidate() + + def _run_on_app_loop(fn) -> bool: + if in_main_thread or app_loop is None: + fn() + return True + ready = threading.Event() + + def _wrapped() -> None: + try: + fn() + finally: + ready.set() + + try: + app_loop.call_soon_threadsafe(_wrapped) + except Exception: + return False + return ready.wait(timeout=5) + + if not _run_on_app_loop(_setup_modal): + return self._prompt_text_input("Choice [1/2/3]: ") _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() + _run_on_app_loop(_teardown_modal) return result except queue.Empty: remaining = self._slash_confirm_deadline - _time.monotonic() @@ -7217,10 +7248,7 @@ def _prompt_text_input_modal( 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() + _run_on_app_loop(_teardown_modal) return None def _submit_slash_confirm_response(self, value: str | None) -> None: -
tests/cli/test_slash_confirm_windows.py@@ -1,14 +1,14 @@ -"""Regression tests for issue #30768: /reset and /new freeze on Windows. +"""Regression tests for issue #30768 and #32383. ``_prompt_text_input_modal`` uses a queue-based modal that relies on prompt_toolkit key bindings receiving keyboard events. On Windows the prompt_toolkit input channel can deadlock when the modal is entered from the ``process_loop`` daemon thread. The fix falls back to the simpler -``_prompt_text_input`` (stdin-based) prompt on Windows and non-main threads. +``_prompt_text_input`` (stdin-based) prompt on Windows. These tests verify: 1. Windows detection triggers the stdin fallback -2. Non-main thread detection triggers the stdin fallback +2. Non-Windows daemon threads still use the modal via the app loop 3. macOS/Linux main-thread path still uses the modal (no regression) 4. No-app path still uses the stdin fallback (existing behavior) 5. Empty choices returns None (existing behavior) @@ -29,6 +29,7 @@ def _make_cli(): obj = object.__new__(cli_mod.HermesCLI) obj._app = MagicMock() + obj._app.loop = MagicMock() obj._status_bar_visible = True obj._last_invalidate = 0.0 obj._modal_input_snapshot = None @@ -66,28 +67,47 @@ def test_windows_falls_back_to_stdin(self): mock_stdin.assert_called_once_with("Choice [1/2/3]: ") assert result == "1" - def test_non_main_thread_falls_back_to_stdin(self): - """Off the main thread, _prompt_text_input_modal should use stdin fallback.""" + def test_non_main_thread_uses_modal_via_app_loop(self): + """Off the main thread on Linux, keep the modal path via app-loop setup.""" cli = _make_cli() result_holder = {} + setup_calls = [] + teardown_calls = [] + + def _call_soon_threadsafe(callback): + callback() def run_on_daemon(): - # Patch platform to "linux" so the Windows check doesn't short-circuit. with patch.object(sys, "platform", "linux"), \ - patch.object(cli, "_prompt_text_input", return_value="2") as mock_stdin: + patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe), \ + patch.object(cli, "_prompt_text_input") as mock_stdin, \ + patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: setup_calls.append("capture")), \ + patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: teardown_calls.append("restore")): result_holder["result"] = cli._prompt_text_input_modal( title="⚠️ /reset", detail="This starts a fresh session.", choices=_SAMPLE_CHOICES, + timeout=5, ) result_holder["stdin_called"] = mock_stdin.called + def _submit_after_delay(): + time.sleep(0.2) + state = cli._slash_confirm_state + if state and "response_queue" in state: + state["response_queue"].put("once") + + submitter = threading.Thread(target=_submit_after_delay, daemon=True) t = threading.Thread(target=run_on_daemon, daemon=True) + submitter.start() t.start() t.join(timeout=2.0) + submitter.join(timeout=2.0) assert not t.is_alive(), "daemon thread hung — modal deadlocked" - assert result_holder["stdin_called"] is True - assert result_holder["result"] == "2" + assert result_holder["stdin_called"] is False + assert result_holder["result"] == "once" + assert setup_calls == ["capture"] + assert teardown_calls == ["restore"] def test_main_thread_non_windows_uses_modal(self): """On macOS/Linux main thread, the queue-based modal is still used.""" @@ -182,20 +202,33 @@ def test_windows_fallback_does_not_set_modal_state(self): assert cli._slash_confirm_state is None - def test_non_main_thread_fallback_does_not_set_modal_state(self): - """Verify daemon-thread fallback doesn't leave modal state set.""" + def test_non_main_thread_modal_clears_state(self): + """Verify daemon-thread modal teardown does not leave state behind.""" cli = _make_cli() errors = [] + def _call_soon_threadsafe(callback): + callback() + def run_on_daemon(): try: with patch.object(sys, "platform", "linux"), \ - patch.object(cli, "_prompt_text_input", return_value="1"): + patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe): + def _submit_after_delay(): + time.sleep(0.2) + state = cli._slash_confirm_state + if state and "response_queue" in state: + state["response_queue"].put("cancel") + + submitter = threading.Thread(target=_submit_after_delay, daemon=True) + submitter.start() cli._prompt_text_input_modal( title="⚠️ /new", detail="This starts a fresh session.", choices=_SAMPLE_CHOICES, + timeout=5, ) + submitter.join(timeout=2.0) if cli._slash_confirm_state is not None: errors.append("_slash_confirm_state should be None") except Exception as exc:
-
19 PRs closed without merging
From the close comments: done elsewhere 5 · duplicate 4 · superseded by another PR 4 · merged via another change 3 · stale or abandoned 1 · other 2
- #22972 fix(cli): prevent RuntimeWarning in _prompt_text_input from background thread · done elsewhere
-
#22989 fix(cli): use prompt_toolkit.prompt() for TUI confirmation prompts (Fixes #22958) · done elsewhere →
#22958 -
#23078 fix(cli): add main-thread guard to _prompt_text_input — prevent RuntimeWarning (#22970) · duplicate →
#22972 - #23257 fix(cli): avoid run_in_terminal from worker threads · done elsewhere
-
#23317 fix(cli): suppress RuntimeWarning from unawaited run_in_terminal in prompt_toolkit 3.0.52+ · superseded by another PR →
#23454
Show 14 more
-
#23348 fix(cli): use prompt_toolkit prompt for confirmation input · duplicate →
#22989,#22958 -
#23571 fix: use TUI modal for slash confirmations · merged via another change →
054f568,#23907,#23694,#23853 -
#23749 fix(cli): schedule destructive prompts on app loop · superseded by another PR →
#23907,#23571,#23694 -
#23886 fix(cli): route destructive slash confirmations through main thread e… · superseded by another PR →
#23907,#23571,#23694,#23853 -
#24336 fix(cli): schedule /new /clear /undo confirm prompt on PT event loop from daemon thread · superseded by another PR →
#22958,#22970 -
#24722 fix(cli): defer destructive slash resets until active turns exit · done elsewhere →
458a94e,#24701,#36833 -
#26617 fix(cli): await run_in_terminal futures to prevent RuntimeWarning · merged via another change →
c5f1f86,365da2d,#23454,#23302 -
#30773 fix(cli): prevent /reset and /new freeze on Windows by falling back to stdin prompt · merged via another change →
#32013,#30768 -
#32387 fix(cli): keep destructive slash modal on Linux · other →
#33241,#33165,#32383 -
#32657 fix: add timeout protection to /new and /clear commands (Issue #32383) · duplicate →
#32387 -
#32808 fix(cli): keep slash confirmation modal on app loop · duplicate →
#32387,#32013,#32383,#22958 -
#37237 fix(cli): restore cooked mode before input() on MSYS2/Git Bash to prevent deadlock · other →
ab98818 -
#37297 fix(cli): restore cooked terminal mode on Windows/MSYS2 to prevent input() deadlock (#37230) · stale or abandoned →
#37230,#36844 -
#75084 fix(cli): stop /reload-mcp from blocking the command processor (supersedes #39446) · done elsewhere →
972aa33
Closed issues · 20
- Closed#21348RuntimeWarning: coroutine 'run_in_terminal.<locals>.run' was never awaited during /reload-mcp
- Closed#22241/reload-mcp confirmation prompt is broken — async coroutine never awaited
- Closed#22958[Bug]: /clear, /new, /reset, /undo confirmation prompt cannot be answered — keystrokes leak into chat composer
- Closed#22970fix(cli): RuntimeWarning in /new, /clear, /undo, /reload-mcp — coroutine was never awaited
- Closed#23009bug: /reload-mcp triggers RuntimeWarning from unawaited run_in_terminal coroutine
- Closed#23185run_in_terminal() coroutines not awaited — silent message loss (output) + broken input on WSL
- Closed#23297[Bug]: RuntimeWarning “run_in_terminal... was never awaited” when confirming /clear or /new in CLI
- Closed#23567[Bug]: When using the Hermes Agent in WSL, running the /clear command will cause the program to freeze.
- Closed#23694[Bug]: Terminal hang when prompting for confirmation on destructive slash commands (/new, /clear) (windows beta)
- Closed#23853/reload-mcp deadlock: _prompt_text_input input() blocks daemon thread in raw mode
- Closed#23920[Bug]: '/new' confirmation freezes session
- Closed#24343/new and /clear hang on confirmation prompt — daemon-thread input() competes with prompt_toolkit stdin
- Closed#24701CLI /new can stall at destructive confirmation and repeat prior task
- Closed#30768Bug: /reset and /new slash commands freeze on Windows — routes to interactive CLI menu
- Closed#32207[Bug]: /clear command freezes or bugs the terminal
- Closed#32383fix(cli): /new, /clear, /reset freeze permanently on Linux after fae815a (PR #30773)
- Closed#33165[Bug]: macOS:When Hermes is connected to DeepSeek, using the /new command causes it to freeze completely.
- Closed#33961[Bug]: /new, /clear, and /reset commands freeze the terminal session
- Closed#34140[Bug]: CLI freezes after using /new command (WSL)
- Closed#37230[Bug]: /new and /clear hang/freeze the Hermes CLI process on Windows 11 (Git Bash MSYS2)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.05: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means 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