Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6428,6 +6428,29 @@ def _should_handle_model_command_inline(self, text: str, has_images: bool = Fals
except Exception:
return False

# Commands that call _prompt_text_input — must run on the UI thread so
# run_in_terminal works instead of falling back to a daemon-thread
# input() that races with prompt_toolkit for stdin.
_INTERACTIVE_SLASH_COMMANDS: frozenset[str] = frozenset({
"model", "new", "clear", "undo", "reset", "reload-mcp",
})

def _should_handle_interactive_slash_inline(
self, text: str, has_images: bool = False
) -> bool:
"""Return True for slash commands that need the UI thread (model picker,
destructive confirm prompts, etc.) so they can use prompt_toolkit's
``run_in_terminal`` instead of a daemon-thread ``input()`` fallback."""
if not text or has_images or not _looks_like_slash_command(text):
return False
try:
from hermes_cli.commands import resolve_command
base = text.split(None, 1)[0].lower().lstrip('/')
cmd = resolve_command(base)
return bool(cmd and cmd.name in self._INTERACTIVE_SLASH_COMMANDS)
except Exception:
return False

def _should_handle_steer_command_inline(self, text: str, has_images: bool = False) -> bool:
"""Return True when /steer should be dispatched immediately while the agent is running.

Expand Down Expand Up @@ -11071,9 +11094,11 @@ def handle_enter(event):
text = event.app.current_buffer.text.strip()
has_images = bool(self._attached_images)
if text or has_images:
# Handle /model directly on the UI thread so interactive pickers
# can safely use prompt_toolkit terminal handoff helpers.
if self._should_handle_model_command_inline(text, has_images=has_images):
# Handle interactive slash commands (/model, /new, /clear,
# /undo, /reset) directly on the UI thread so they can safely
# use prompt_toolkit terminal handoff helpers (run_in_terminal)
# instead of a daemon-thread input() that races with stdin.
if self._should_handle_interactive_slash_inline(text, has_images=has_images):
if not self.process_command(text):
self._should_exit = True
if event.app.is_running:
Expand Down
79 changes: 79 additions & 0 deletions tests/cli/test_interactive_slash_inline_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Tests that destructive/interactive slash commands are routed inline on the
UI thread so _prompt_text_input uses run_in_terminal instead of a daemon-thread
input() that races with prompt_toolkit for stdin."""

from __future__ import annotations

from unittest.mock import MagicMock


def _make_cli():
"""Build a minimal HermesCLI-like stand-in for gate checks."""
from cli import HermesCLI

cli = HermesCLI.__new__(HermesCLI)
cli._app = MagicMock() # truthy so the inline path is active
return cli


def test_model_commands_routed_inline():
"""/model still routes inline."""
cli = _make_cli()
assert cli._should_handle_interactive_slash_inline("/model") is True
assert cli._should_handle_interactive_slash_inline("/model gpt-5") is True


def test_new_routed_inline():
cli = _make_cli()
assert cli._should_handle_interactive_slash_inline("/new") is True
assert cli._should_handle_interactive_slash_inline("/new my session") is True


def test_clear_routed_inline():
cli = _make_cli()
assert cli._should_handle_interactive_slash_inline("/clear") is True


def test_undo_routed_inline():
cli = _make_cli()
assert cli._should_handle_interactive_slash_inline("/undo") is True


def test_reset_routed_inline():
cli = _make_cli()
assert cli._should_handle_interactive_slash_inline("/reset") is True


def test_non_interactive_commands_not_routed_inline():
"""Non-interactive slash commands (no _prompt_text_input) should NOT
route inline — they work fine through the process_loop daemon thread."""
cli = _make_cli()
assert cli._should_handle_interactive_slash_inline("/help") is False
assert cli._should_handle_interactive_slash_inline("/status") is False
assert cli._should_handle_interactive_slash_inline("/stop") is False
assert cli._should_handle_interactive_slash_inline("/title") is False


def test_non_slash_input_not_routed_inline():
cli = _make_cli()
assert cli._should_handle_interactive_slash_inline("hello") is False
assert cli._should_handle_interactive_slash_inline("") is False


def test_images_attached_not_routed_inline():
"""When images are attached, the inline path is skipped — the command
can't be handled reliably inline with image data pending."""
cli = _make_cli()
assert cli._should_handle_interactive_slash_inline("/new", has_images=True) is False


def test_reload_mcp_routed_inline():
cli = _make_cli()
assert cli._should_handle_interactive_slash_inline("/reload-mcp") is True


def test_old_gate_still_works():
"""_should_handle_model_command_inline still works (backward compat)."""
cli = _make_cli()
assert cli._should_handle_model_command_inline("/model") is True
assert cli._should_handle_model_command_inline("/new") is False # old gate excludes non-model
Loading