Skip to content
Open
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
103 changes: 99 additions & 4 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1806,6 +1806,9 @@ 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; 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
self._clarify_state = None
Expand Down Expand Up @@ -2118,6 +2121,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)
Expand Down Expand Up @@ -8326,14 +8334,83 @@ def handle_enter(event):

@kb.add('escape', 'enter')
def handle_alt_enter(event):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tuple is no longer Alt-only on current main: Shift+Enter and enhanced-terminal Ctrl+Enter are intentionally aliased to (Escape, ControlM) so they reach the multiline-newline handler (tests/cli/test_cli_shift_enter_newline.py:40-78, tests/cli/test_ctrl_enter_newline.py:97-109). Reassigning it to queueing would change all of those shortcuts as well.

"""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

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)
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('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)
item = cli_ref._followup_queue.pop()
recalled_text = item["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
if cli_ref._followup_recall_count > 0 and current.strip():
buf.text = current.rstrip() + '\n---\n' + recalled_text
else:
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()

@kb.add('tab', eager=True)
def handle_tab(event):
"""Tab: accept completion, auto-suggestion, or start completions.
Expand Down Expand Up @@ -8843,7 +8920,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 ""
Expand Down Expand Up @@ -9391,6 +9474,18 @@ def process_loop():
# Check for pending input with timeout
try:
user_input = self._pending_input.get(timeout=0.1)
# 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:
Expand Down
140 changes: 140 additions & 0 deletions tests/test_cli_followup_queue.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading