From d8ba475ac32aa3179eac43e6f040f2079a366ee6 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 11:37:31 -0400 Subject: [PATCH 1/5] feat: Alt+Enter queues follow-up messages without interrupting agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alt+Enter now queues the current input as a follow-up to be sent after the agent finishes responding, instead of inserting a newline. - Alt+Enter โ†’ puts message into _pending_input (non-interrupting) - Enter (agent running) โ†’ still interrupts via _interrupt_queue - _followup_queue list mirrors pending items for display - Status bar shows ๐Ÿ“ฌ N when follow-ups are queued - Placeholder hints update: shows queue depth while agent runs, and persists after it finishes until queue drains - Ctrl+J remains the newline key for multi-line input Closes: the need for Shift+Enter queue (terminals can't distinguish Shift+Enter from Enter; Alt+Enter is the reliable alternative) --- cli.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index eb19f43f196f3..9a0e9d599ede5 100644 --- a/cli.py +++ b/cli.py @@ -1806,6 +1806,7 @@ def __init__( self._agent_running = False self._pending_input = queue.Queue() self._interrupt_queue = queue.Queue() + self._followup_queue: list = [] # mirror of _pending_input for display (Alt+Enter queued messages) self._should_exit = False self._last_ctrl_c_time = 0 self._clarify_state = None @@ -2118,6 +2119,11 @@ def _get_status_bar_fragments(self): ("class:status-bar", " "), ] + # Follow-up queue indicator + if self._followup_queue: + frags.append(("class:status-bar-dim", " โ”‚ ")) + frags.append(("class:status-bar-warn", f"๐Ÿ“ฌ {len(self._followup_queue)}")) + total_width = sum(self._status_bar_display_width(text) for _, text in frags) if total_width > width: plain_text = "".join(text for _, text in frags) @@ -8326,12 +8332,40 @@ def handle_enter(event): @kb.add('escape', 'enter') def handle_alt_enter(event): - """Alt+Enter inserts a newline for multi-line input.""" - event.current_buffer.insert_text('\n') + """Alt+Enter: queue message as follow-up (sent after current response). + + When agent is idle, behaves like Enter (sends immediately to _pending_input). + When agent is running, queues without interrupting โ€” sent as the next turn. + _followup_queue mirrors what's pending for status display. + Use Ctrl+J / Ctrl+Enter for inserting a newline in multi-line input. + """ + if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._clarify_state or cli_ref._approval_state: + return + + text = event.app.current_buffer.text.strip() + has_images = bool(cli_ref._attached_images) + if not text and not has_images: + return + + images = list(cli_ref._attached_images) + cli_ref._attached_images.clear() + payload = (text, images) if images else text + + cli_ref._pending_input.put(payload) + cli_ref._followup_queue.append(payload) + event.app.current_buffer.reset(append_to_history=True) + + queue_depth = len(cli_ref._followup_queue) + preview = text[:60] + ("..." if len(text) > 60 else "") + if cli_ref._agent_running: + _cprint(f" {_DIM}๐Ÿ“ฌ Queued follow-up #{queue_depth}: \"{preview}\"{_RST}") + else: + _cprint(f" {_DIM}๐Ÿ“ฌ Queued: \"{preview}\"{_RST}") + event.app.invalidate() @kb.add('c-j') def handle_ctrl_enter(event): - """Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter.""" + """Ctrl+J (Ctrl+Enter in most terminals): insert a newline for multi-line input.""" event.current_buffer.insert_text('\n') @kb.add('tab', eager=True) @@ -8843,7 +8877,13 @@ def _get_placeholder(): status = cli_ref._command_status or "Processing command..." return f"{frame} {status}" if cli_ref._agent_running: - return "type a message + Enter to interrupt, Ctrl+C to cancel" + hints = [] + if cli_ref._followup_queue: + hints.append(f"๐Ÿ“ฌ {len(cli_ref._followup_queue)} queued") + suffix = " ยท " + " ยท ".join(hints) if hints else "" + return f"Enter to interrupt ยท Alt+Enter to queue follow-up{suffix}" + if cli_ref._followup_queue: + return f"๐Ÿ“ฌ {len(cli_ref._followup_queue)} follow-up{'s' if len(cli_ref._followup_queue) > 1 else ''} queued โ€” Alt+Enter to add more" if cli_ref._voice_mode: return "type or Ctrl+B to record" return "" @@ -9391,6 +9431,10 @@ def process_loop(): # Check for pending input with timeout try: user_input = self._pending_input.get(timeout=0.1) + # Keep _followup_queue in sync โ€” pop the oldest entry if present + if self._followup_queue: + self._followup_queue.pop(0) + app.invalidate() except queue.Empty: # Periodic config watcher โ€” auto-reload MCP on mcp_servers change if not self._agent_running: From a66f37b9301c42889a3929513cec571d84dc0271 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 11:43:41 -0400 Subject: [PATCH 2/5] feat: Alt+Up recalls queued follow-ups back into input Alt+Up pops the most recently queued follow-up (LIFO) from _followup_queue, appends it to the current input with a newline--- separator, and marks it cancelled so process_loop skips it. Repeated Alt+Up recalls one at a time until queue is empty. _cancelled_followups set is checked in process_loop and discarded on match to avoid sending the recalled message twice. --- cli.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/cli.py b/cli.py index 9a0e9d599ede5..a079e277845a2 100644 --- a/cli.py +++ b/cli.py @@ -1807,6 +1807,7 @@ def __init__( self._pending_input = queue.Queue() self._interrupt_queue = queue.Queue() self._followup_queue: list = [] # mirror of _pending_input for display (Alt+Enter queued messages) + self._cancelled_followups: set = set() # texts recalled via Alt+Up, skipped in process_loop self._should_exit = False self._last_ctrl_c_time = 0 self._clarify_state = None @@ -8368,6 +8369,42 @@ def handle_ctrl_enter(event): """Ctrl+J (Ctrl+Enter in most terminals): insert a newline for multi-line input.""" event.current_buffer.insert_text('\n') + @kb.add('escape', 'up') + def handle_recall_followup(event): + """Alt+Up: recall the most recently queued follow-up back into the input. + + Pops the last item from _followup_queue (LIFO โ€” most recent first) and + appends its text to the current input, separated by '\\n---\\n'. + If multiple follow-ups are queued, repeated Alt+Up recalls them one by one. + The recalled item is added to _cancelled_followups so process_loop skips it. + """ + if not cli_ref._followup_queue: + return + + buf = event.app.current_buffer + + # Pop the most recently queued item (last = most recent) + payload = cli_ref._followup_queue.pop() + recalled_text = payload[0] if isinstance(payload, tuple) else payload + + # Mark as cancelled so process_loop skips it when dequeued + cli_ref._cancelled_followups.add(recalled_text) + + # Append to current buffer with separator + current = buf.text + if current.strip(): + buf.text = current.rstrip() + '\n---\n' + recalled_text + else: + buf.text = recalled_text + buf.cursor_position = len(buf.text) + + remaining = len(cli_ref._followup_queue) + if remaining: + _cprint(f" {_DIM}๐Ÿ“ฌ Recalled follow-up ({remaining} still queued){_RST}") + else: + _cprint(f" {_DIM}๐Ÿ“ฌ Follow-up recalled โ€” queue empty{_RST}") + event.app.invalidate() + @kb.add('tab', eager=True) def handle_tab(event): """Tab: accept completion, auto-suggestion, or start completions. @@ -9435,6 +9472,11 @@ def process_loop(): if self._followup_queue: self._followup_queue.pop(0) app.invalidate() + # Skip items recalled via Alt+Up + _input_text = user_input[0] if isinstance(user_input, tuple) else user_input + if _input_text in self._cancelled_followups: + self._cancelled_followups.discard(_input_text) + continue except queue.Empty: # Periodic config watcher โ€” auto-reload MCP on mcp_servers change if not self._agent_running: From a9167a87e25d12fa46a05eb1c0970f5b24f6de2e Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 11:45:59 -0400 Subject: [PATCH 3/5] fix: no separator before first recalled follow-up --- cli.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index a079e277845a2..f9d52c0f4211a 100644 --- a/cli.py +++ b/cli.py @@ -1808,6 +1808,7 @@ def __init__( self._interrupt_queue = queue.Queue() self._followup_queue: list = [] # mirror of _pending_input for display (Alt+Enter queued messages) self._cancelled_followups: set = set() # texts recalled via Alt+Up, skipped in process_loop + self._followup_recall_count: int = 0 # how many recalls done in this recall session self._should_exit = False self._last_ctrl_c_time = 0 self._clarify_state = None @@ -8390,18 +8391,20 @@ def handle_recall_followup(event): # Mark as cancelled so process_loop skips it when dequeued cli_ref._cancelled_followups.add(recalled_text) - # Append to current buffer with separator + # Append to current buffer โ€” separator only from the second recall onwards current = buf.text - if current.strip(): + if cli_ref._followup_recall_count > 0 and current.strip(): buf.text = current.rstrip() + '\n---\n' + recalled_text else: - buf.text = recalled_text + buf.text = (current + recalled_text) if current else recalled_text buf.cursor_position = len(buf.text) + cli_ref._followup_recall_count += 1 remaining = len(cli_ref._followup_queue) if remaining: _cprint(f" {_DIM}๐Ÿ“ฌ Recalled follow-up ({remaining} still queued){_RST}") else: + cli_ref._followup_recall_count = 0 _cprint(f" {_DIM}๐Ÿ“ฌ Follow-up recalled โ€” queue empty{_RST}") event.app.invalidate() From fbd6334d73124b6b5be85742ebef1e608f87d91c Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Fri, 3 Apr 2026 13:36:44 -0400 Subject: [PATCH 4/5] fix: use UUID tags for followup cancellation, fix phantom queue pops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback from britrik (#4788): - Replace text-based cancellation with UUID tags โ€” identical messages queued twice no longer cancel each other incorrectly - Wrap Alt+Enter payloads as {_followup_tag, payload} dicts so process_loop can identify followup items by ID, not content - Fix phantom _followup_queue pops: display sync now only happens for tagged (Alt+Enter) items, not regular Enter messages - _cancelled_followups stores UUIDs (bounded, auto-discarded on match) Note: the image-payload cancel check was already correct in the original โ€” both sides extracted text via payload[0] โ€” but UUID tagging makes the intent unambiguous regardless of payload shape. --- cli.py | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/cli.py b/cli.py index f9d52c0f4211a..738753029e4c9 100644 --- a/cli.py +++ b/cli.py @@ -1806,8 +1806,8 @@ def __init__( self._agent_running = False self._pending_input = queue.Queue() self._interrupt_queue = queue.Queue() - self._followup_queue: list = [] # mirror of _pending_input for display (Alt+Enter queued messages) - self._cancelled_followups: set = set() # texts recalled via Alt+Up, skipped in process_loop + self._followup_queue: list = [] # mirror of _pending_input for display; entries are {"id": str, "payload": ...} + self._cancelled_followups: set = set() # UUIDs recalled via Alt+Up, skipped in process_loop self._followup_recall_count: int = 0 # how many recalls done in this recall session self._should_exit = False self._last_ctrl_c_time = 0 @@ -8353,8 +8353,11 @@ def handle_alt_enter(event): cli_ref._attached_images.clear() payload = (text, images) if images else text - cli_ref._pending_input.put(payload) - cli_ref._followup_queue.append(payload) + import uuid as _uuid_mod + tag = _uuid_mod.uuid4().hex + # Wrap with tag so process_loop can identify and cancel by ID, not text + cli_ref._pending_input.put({"_followup_tag": tag, "payload": payload}) + cli_ref._followup_queue.append({"id": tag, "payload": payload, "text": text}) event.app.current_buffer.reset(append_to_history=True) queue_depth = len(cli_ref._followup_queue) @@ -8385,11 +8388,11 @@ def handle_recall_followup(event): buf = event.app.current_buffer # Pop the most recently queued item (last = most recent) - payload = cli_ref._followup_queue.pop() - recalled_text = payload[0] if isinstance(payload, tuple) else payload + item = cli_ref._followup_queue.pop() + recalled_text = item["text"] - # Mark as cancelled so process_loop skips it when dequeued - cli_ref._cancelled_followups.add(recalled_text) + # Cancel by UUID โ€” immune to duplicate-text false positives + cli_ref._cancelled_followups.add(item["id"]) # Append to current buffer โ€” separator only from the second recall onwards current = buf.text @@ -9471,15 +9474,18 @@ def process_loop(): # Check for pending input with timeout try: user_input = self._pending_input.get(timeout=0.1) - # Keep _followup_queue in sync โ€” pop the oldest entry if present - if self._followup_queue: - self._followup_queue.pop(0) - app.invalidate() - # Skip items recalled via Alt+Up - _input_text = user_input[0] if isinstance(user_input, tuple) else user_input - if _input_text in self._cancelled_followups: - self._cancelled_followups.discard(_input_text) - continue + # Unwrap tagged followup items (queued via Alt+Enter) + if isinstance(user_input, dict) and "_followup_tag" in user_input: + tag = user_input["_followup_tag"] + user_input = user_input["payload"] + # Sync display mirror โ€” only pop for tagged items, not regular Enter + if self._followup_queue: + self._followup_queue.pop(0) + app.invalidate() + # Skip items recalled via Alt+Up (cancelled by UUID, not text) + if tag in self._cancelled_followups: + self._cancelled_followups.discard(tag) + continue except queue.Empty: # Periodic config watcher โ€” auto-reload MCP on mcp_servers change if not self._agent_running: From efa94d34388b1da037eae876d1cb446bbf6305e6 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Mon, 6 Apr 2026 14:26:42 -0400 Subject: [PATCH 5/5] test: add tests for PR #4788 --- tests/test_cli_followup_queue.py | 140 +++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/test_cli_followup_queue.py diff --git a/tests/test_cli_followup_queue.py b/tests/test_cli_followup_queue.py new file mode 100644 index 0000000000000..88583eaf96f99 --- /dev/null +++ b/tests/test_cli_followup_queue.py @@ -0,0 +1,140 @@ +"""Tests for PR #4788 feat/queue-followup. + +Covers: Alt+Enter queues followup messages. +Attributes on HermesCLI: _followup_queue, _cancelled_followups, _followup_recall_count. +""" + +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +def _make_cli(env_overrides=None, config_overrides=None, **kwargs): + """Create a HermesCLI instance with minimal mocking (mirrors test_cli_init.py).""" + import importlib + + _clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": "all"}, + "agent": {}, + "terminal": {"env_type": "local"}, + } + if config_overrides: + for key, value in config_overrides.items(): + if key in _clean_config and isinstance(_clean_config[key], dict) and isinstance(value, dict): + _clean_config[key] = {**_clean_config[key], **value} + else: + _clean_config[key] = value + + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + if env_overrides: + clean_env.update(env_overrides) + + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + "prompt_toolkit.auto_suggest": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), \ + patch.dict("os.environ", clean_env, clear=False): + import cli as _cli_mod + _cli_mod = importlib.reload(_cli_mod) + with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), \ + patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}): + return _cli_mod.HermesCLI(**kwargs) + + +class TestFollowupQueueInit: + """_followup_queue must be initialized as an empty list.""" + + def test_followup_queue_attribute_exists(self): + cli = _make_cli() + assert hasattr(cli, "_followup_queue"), ( + "_followup_queue must be initialized in HermesCLI.__init__" + ) + + def test_followup_queue_is_empty_list(self): + cli = _make_cli() + assert cli._followup_queue == [] + + def test_followup_queue_is_list_type(self): + cli = _make_cli() + assert isinstance(cli._followup_queue, list), ( + "_followup_queue must be a list (not a queue or deque)" + ) + + +class TestCancelledFollowupsInit: + """_cancelled_followups must be initialized as an empty set.""" + + def test_cancelled_followups_attribute_exists(self): + cli = _make_cli() + assert hasattr(cli, "_cancelled_followups"), ( + "_cancelled_followups must be initialized in HermesCLI.__init__" + ) + + def test_cancelled_followups_is_empty_set(self): + cli = _make_cli() + assert cli._cancelled_followups == set() + + def test_cancelled_followups_is_set_type(self): + cli = _make_cli() + assert isinstance(cli._cancelled_followups, set), ( + "_cancelled_followups must be a set (not a list)" + ) + + +class TestFollowupRecallCountInit: + """_followup_recall_count must be initialized to 0.""" + + def test_followup_recall_count_attribute_exists(self): + cli = _make_cli() + assert hasattr(cli, "_followup_recall_count"), ( + "_followup_recall_count must be initialized in HermesCLI.__init__" + ) + + def test_followup_recall_count_is_zero(self): + cli = _make_cli() + assert cli._followup_recall_count == 0 + + def test_followup_recall_count_is_int(self): + cli = _make_cli() + assert isinstance(cli._followup_recall_count, int) + + +class TestFollowupQueueIndependence: + """Each HermesCLI instance has its own queue/set (no shared mutable defaults).""" + + def test_two_instances_have_independent_queues(self): + cli_a = _make_cli() + cli_b = _make_cli() + cli_a._followup_queue.append("item") + assert cli_b._followup_queue == [], ( + "_followup_queue must not be shared between instances" + ) + + def test_two_instances_have_independent_cancelled_sets(self): + cli_a = _make_cli() + cli_b = _make_cli() + cli_a._cancelled_followups.add("uid-abc") + assert cli_b._cancelled_followups == set(), ( + "_cancelled_followups must not be shared between instances" + )