diff --git a/cli.py b/cli.py index bb1bdb61764..79bde920a23 100644 --- a/cli.py +++ b/cli.py @@ -2558,6 +2558,8 @@ class HermesCLI: 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 @@ class HermesCLI: 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 self._slash_confirm_state 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 @@ class HermesCLI: _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 @@ class HermesCLI: 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 @@ class HermesCLI: 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 @@ class HermesCLI: return _state_fragment("class:sudo-prompt", "🔑") if self._approval_state: return _state_fragment("class:prompt-working", "⚠") + if self._slash_confirm_state: + return _state_fragment("class:prompt-working", "⚠") if self._clarify_freetext: return _state_fragment("class:clarify-selected", "✎") if self._clarify_state: @@ -10660,6 +10842,7 @@ class HermesCLI: sudo_widget, secret_widget, approval_widget, + slash_confirm_widget, clarify_widget, model_picker_widget=None, spinner_widget=None, @@ -10684,6 +10867,7 @@ class HermesCLI: sudo_widget, secret_widget, approval_widget, + slash_confirm_widget, clarify_widget, model_picker_widget, spinner_widget, @@ -10846,6 +11030,13 @@ class HermesCLI: 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 @@ class HermesCLI: 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 @@ class HermesCLI: 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 @@ class HermesCLI: _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 @@ class HermesCLI: 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 @@ class HermesCLI: 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 @@ class HermesCLI: 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 @@ class HermesCLI: 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 @@ class HermesCLI: # 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 @@ class HermesCLI: 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 @@ class HermesCLI: ('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 @@ class HermesCLI: 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 @@ class HermesCLI: 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 @@ class HermesCLI: sudo_widget=sudo_widget, secret_widget=secret_widget, approval_widget=approval_widget, + slash_confirm_widget=slash_confirm_widget, clarify_widget=clarify_widget, model_picker_widget=model_picker_widget, spinner_widget=spinner_widget, diff --git a/tests/cli/test_destructive_slash_confirm.py b/tests/cli/test_destructive_slash_confirm.py index 290314dc371..1b2fc8c0b1f 100644 --- a/tests/cli/test_destructive_slash_confirm.py +++ b/tests/cli/test_destructive_slash_confirm.py @@ -6,6 +6,7 @@ don't have to construct a full HermesCLI (which requires extensive setup). 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 diff --git a/tests/cli/test_prompt_text_input_thread_safety.py b/tests/cli/test_prompt_text_input_thread_safety.py index 7b9af5e0e8b..fb27a95b312 100644 --- a/tests/cli/test_prompt_text_input_thread_safety.py +++ b/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 @@ from unittest.mock import MagicMock, patch 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 @@ class TestPromptTextInputThreadSafety: 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 @@ class TestPromptTextInputThreadSafety: 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 = {}