feat(cli): configurable busy input mode + fix /queue always working (#3298)

Two changes:

1. Fix /queue command: remove the _agent_running guard that rejected
   /queue after the agent finished. The prompt was deferred in
   _pending_input until the agent completed, then the handler checked
   _agent_running (now False) and rejected it. /queue now always queues
   regardless of timing.

2. Add display.busy_input_mode config (CLI-only):
   - 'interrupt' (default): Enter while busy interrupts the current run
     (preserves existing behavior)
   - 'queue': Enter while busy queues the message for the next turn,
     with a 'Queued for the next turn: ...' confirmation
   Ctrl+C always interrupts regardless of this setting.

Salvaged from PR #3037 by StefanoChiodino. Key differences:
- Default is 'interrupt' (preserves existing behavior) not 'queue'
- No config version bump (unnecessary for new key in existing section)
- Simpler normalization (no alias map)
- /queue fix is simpler: just remove the guard instead of intercepting
  commands during busy state
This commit is contained in:
Teknium
2026-03-26 17:58:40 -07:00
committed by GitHub
parent 0375b2a0d7
commit 2d232c9991
4 changed files with 89 additions and 19 deletions

View File

@@ -688,6 +688,12 @@ display:
# Toggle at runtime with /verbose in the CLI # Toggle at runtime with /verbose in the CLI
tool_progress: all tool_progress: all
# What Enter does when Hermes is already busy in the CLI.
# interrupt: Interrupt the current run and redirect Hermes (default)
# queue: Queue your message for the next turn
# Ctrl+C always interrupts regardless of this setting.
busy_input_mode: interrupt
# Background process notifications (gateway/messaging only). # Background process notifications (gateway/messaging only).
# Controls how chatty the process watcher is when you use # Controls how chatty the process watcher is when you use
# terminal(background=true, check_interval=...) from Telegram/Discord/etc. # terminal(background=true, check_interval=...) from Telegram/Discord/etc.

48
cli.py
View File

@@ -205,6 +205,7 @@ def load_cli_config() -> Dict[str, Any]:
"resume_display": "full", "resume_display": "full",
"show_reasoning": False, "show_reasoning": False,
"streaming": True, "streaming": True,
"busy_input_mode": "interrupt",
"skin": "default", "skin": "default",
}, },
@@ -1042,6 +1043,9 @@ class HermesCLI:
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False) self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
# show_reasoning: display model thinking/reasoning before the response # show_reasoning: display model thinking/reasoning before the response
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False) self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
# busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn)
_bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt")
self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt"
self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose") self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose")
@@ -3736,17 +3740,17 @@ class HermesCLI:
elif canonical == "background": elif canonical == "background":
self._handle_background_command(cmd_original) self._handle_background_command(cmd_original)
elif canonical == "queue": elif canonical == "queue":
if not self._agent_running: # Extract prompt after "/queue " or "/q "
_cprint(" /queue only works while Hermes is busy. Just type your message normally.") parts = cmd_original.split(None, 1)
payload = parts[1].strip() if len(parts) > 1 else ""
if not payload:
_cprint(" Usage: /queue <prompt>")
else: else:
# Extract prompt after "/queue " or "/q " self._pending_input.put(payload)
parts = cmd_original.split(None, 1) if self._agent_running:
payload = parts[1].strip() if len(parts) > 1 else ""
if not payload:
_cprint(" Usage: /queue <prompt>")
else:
self._pending_input.put(payload)
_cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}") _cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
else:
_cprint(f" Queued: {payload[:80]}{'...' if len(payload) > 80 else ''}")
elif canonical == "skin": elif canonical == "skin":
self._handle_skin_command(cmd_original) self._handle_skin_command(cmd_original)
elif canonical == "voice": elif canonical == "voice":
@@ -6126,16 +6130,22 @@ class HermesCLI:
# Bundle text + images as a tuple when images are present # Bundle text + images as a tuple when images are present
payload = (text, images) if images else text payload = (text, images) if images else text
if self._agent_running and not (text and text.startswith("/")): if self._agent_running and not (text and text.startswith("/")):
self._interrupt_queue.put(payload) if self.busy_input_mode == "queue":
# Debug: log to file when message enters interrupt queue # Queue for the next turn instead of interrupting
try: self._pending_input.put(payload)
_dbg = _hermes_home / "interrupt_debug.log" preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]"
with open(_dbg, "a") as _f: _cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}")
import time as _t else:
_f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " self._interrupt_queue.put(payload)
f"agent_running={self._agent_running}\n") # Debug: log to file when message enters interrupt queue
except Exception: try:
pass _dbg = _hermes_home / "interrupt_debug.log"
with open(_dbg, "a") as _f:
import time as _t
_f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
f"agent_running={self._agent_running}\n")
except Exception:
pass
else: else:
self._pending_input.put(payload) self._pending_input.put(payload)
event.app.current_buffer.reset(append_to_history=True) event.app.current_buffer.reset(append_to_history=True)

View File

@@ -264,6 +264,7 @@ DEFAULT_CONFIG = {
"compact": False, "compact": False,
"personality": "kawaii", "personality": "kawaii",
"resume_display": "full", "resume_display": "full",
"busy_input_mode": "interrupt",
"bell_on_complete": False, "bell_on_complete": False,
"show_reasoning": False, "show_reasoning": False,
"streaming": False, "streaming": False,

View File

@@ -96,6 +96,59 @@ class TestVerboseAndToolProgress:
assert cli.tool_progress_mode in ("off", "new", "all", "verbose") assert cli.tool_progress_mode in ("off", "new", "all", "verbose")
class TestBusyInputMode:
def test_default_busy_input_mode_is_interrupt(self):
cli = _make_cli()
assert cli.busy_input_mode == "interrupt"
def test_busy_input_mode_queue_is_honored(self):
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
assert cli.busy_input_mode == "queue"
def test_unknown_busy_input_mode_falls_back_to_interrupt(self):
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "bogus"}})
assert cli.busy_input_mode == "interrupt"
def test_queue_command_works_while_busy(self):
"""When agent is running, /queue should still put the prompt in _pending_input."""
cli = _make_cli()
cli._agent_running = True
cli.process_command("/queue follow up")
assert cli._pending_input.get_nowait() == "follow up"
def test_queue_command_works_while_idle(self):
"""When agent is idle, /queue should still queue (not reject)."""
cli = _make_cli()
cli._agent_running = False
cli.process_command("/queue follow up")
assert cli._pending_input.get_nowait() == "follow up"
def test_queue_mode_routes_busy_enter_to_pending(self):
"""In queue mode, Enter while busy should go to _pending_input, not _interrupt_queue."""
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
cli._agent_running = True
# Simulate what handle_enter does for non-command input while busy
text = "follow up"
if cli.busy_input_mode == "queue":
cli._pending_input.put(text)
else:
cli._interrupt_queue.put(text)
assert cli._pending_input.get_nowait() == "follow up"
assert cli._interrupt_queue.empty()
def test_interrupt_mode_routes_busy_enter_to_interrupt(self):
"""In interrupt mode (default), Enter while busy goes to _interrupt_queue."""
cli = _make_cli()
cli._agent_running = True
text = "redirect"
if cli.busy_input_mode == "queue":
cli._pending_input.put(text)
else:
cli._interrupt_queue.put(text)
assert cli._interrupt_queue.get_nowait() == "redirect"
assert cli._pending_input.empty()
class TestSingleQueryState: class TestSingleQueryState:
def test_voice_and_interrupt_state_initialized_before_run(self): def test_voice_and_interrupt_state_initialized_before_run(self):
"""Single-query mode calls chat() without going through run().""" """Single-query mode calls chat() without going through run()."""