diff --git a/cli-config.yaml.example b/cli-config.yaml.example index dc1a33199..acdc4ff2d 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -688,6 +688,12 @@ display: # Toggle at runtime with /verbose in the CLI 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). # Controls how chatty the process watcher is when you use # terminal(background=true, check_interval=...) from Telegram/Discord/etc. diff --git a/cli.py b/cli.py index 652cc1ec1..ff81b808e 100644 --- a/cli.py +++ b/cli.py @@ -205,6 +205,7 @@ def load_cli_config() -> Dict[str, Any]: "resume_display": "full", "show_reasoning": False, "streaming": True, + "busy_input_mode": "interrupt", "skin": "default", }, @@ -1042,6 +1043,9 @@ class HermesCLI: self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False) # show_reasoning: display model thinking/reasoning before the response 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") @@ -3736,17 +3740,17 @@ class HermesCLI: elif canonical == "background": self._handle_background_command(cmd_original) elif canonical == "queue": - if not self._agent_running: - _cprint(" /queue only works while Hermes is busy. Just type your message normally.") + # Extract prompt after "/queue " or "/q " + parts = cmd_original.split(None, 1) + payload = parts[1].strip() if len(parts) > 1 else "" + if not payload: + _cprint(" Usage: /queue ") else: - # Extract prompt after "/queue " or "/q " - parts = cmd_original.split(None, 1) - payload = parts[1].strip() if len(parts) > 1 else "" - if not payload: - _cprint(" Usage: /queue ") - else: - self._pending_input.put(payload) + self._pending_input.put(payload) + if self._agent_running: _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": self._handle_skin_command(cmd_original) elif canonical == "voice": @@ -6126,16 +6130,22 @@ class HermesCLI: # Bundle text + images as a tuple when images are present payload = (text, images) if images else text if self._agent_running and not (text and text.startswith("/")): - self._interrupt_queue.put(payload) - # Debug: log to file when message enters interrupt queue - try: - _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 + if self.busy_input_mode == "queue": + # Queue for the next turn instead of interrupting + self._pending_input.put(payload) + preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]" + _cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}") + else: + self._interrupt_queue.put(payload) + # Debug: log to file when message enters interrupt queue + try: + _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: self._pending_input.put(payload) event.app.current_buffer.reset(append_to_history=True) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 745bf9123..2ab681ed6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -264,6 +264,7 @@ DEFAULT_CONFIG = { "compact": False, "personality": "kawaii", "resume_display": "full", + "busy_input_mode": "interrupt", "bell_on_complete": False, "show_reasoning": False, "streaming": False, diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index f41f81bb8..b5598aed1 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -96,6 +96,59 @@ class TestVerboseAndToolProgress: 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: def test_voice_and_interrupt_state_initialized_before_run(self): """Single-query mode calls chat() without going through run()."""